Lumo API Tracker
Developer Tracker

Lumo API

Unofficial development tracker for the Proton Lumo API. Documents what works, what doesn't, and tested Python examples updated as the API evolves.

API v1 — Early Access API Functional Remaining tools to be fixed 1 Last tested: September 17 2026
I welcome any information you may wish to share. Reach out to Threema: *0001337 Note: Keys generation methods are available. Inquire directly for potential details
▶ GET API KEY ▶ OWASP INJECTION ▶ OPENCODE ▶ Lumodroid ▶ GENERATE IMAGES ▶ DESCRIBE IMAGE ▶ Comic Story ▶ SKETCH & DESCRIBE ▶ API DEBUG ▶ CUSTOM AGENTS TEST ▶ RED TEAM
LATEST UPDATES
🔐 NEW: U2L Encryption is now fully supported! Multi-turn encrypted requests work perfectly. See OpenCode Integration section below.
🆕 NEW: Apertus 1.5 is now available among the Lumo Lite and Lumo Max.
🆕 NEW: Voice Prompts allows you talk with Lumo by voice.
🆕 NEW: Lumo can now create/update the user's email signature via a dedicated tool.
🆕 NEW: Lumo can set up email filters on the user's behalf.
🆕 NEW: Lumo can rename existing folders.
🆕 NEW: Lumo can configure automatic replies (vacation responder style).
🆕 NEW: Context search v2 enables content search within messages, eliminating the need to download the index.
🆕 Lumo integration active in sidebar for email read/write/summarize and filter creation. Email composition pending implementation.
🆕 reasoning_effort performance matrix (tested with temp 0.1-0.3):
ValueAvg TimeAvg TokensStatus
"none"~9s~528✅ Fastest
"medium"~17s~1,053🐌 Slowest
"high"~10.5s~940⚡ Moderate
"max"~9.6s~894⚡ Moderate
All 4 values (none, medium, high, max) accepted and working.
Response now includes provider, finish_reason, usage.prompt_tokens_details.cached_tokens, and remaining_limits (lite/max/images)
Models renamed: lumo-lite (fast) and lumo-max (reasoning); auto routes optimally
Chat tools: weather, stock, web_search, cryptocurrency, proton_info
Image tools: generate_image, describe_image, edit_image, web_search, web_extract
Limits endpoint returns static quotas: remaining_limits.lite, remaining_limits.max, remaining_limits.images
Lumo is now integrated on the Mail read/write/summarize and create filters
Public release is estimated at 3/4 months out.
"provider": "lumo",
"finish_reason": "stop",
"usage": {
    "completion_tokens": 55,
    "prompt_tokens": 2136,
    "total_tokens": 2191,
    "prompt_tokens_details": {
        "cached_tokens": 1800
    },
    "remaining_limits": {
        "lite": 19,
        "max": 20,
        "images": 20
    },
    "applied_limit_category": "lite"
    
        
Overview

Feature Status

Tested directly against the live API. The Lumo API launched recently and the backend does not yet expose all features described in the frontend documentation config.

POST /chat/completions
Core endpoint works. Can be turned on/off and is now required.
POST /account/1337/personal-access-token
Core endpoint works. Allows to list and create keys (requires special commands not disclosed here.)
temperature
The API just ignores this field.
system role
System prompt in messages array works and influences tone and behavior.
Multi-turn conversations
Full message history with user/assistant alternation works correctly.
stream: True
Streaming is enabled. API always returns SSE stream.
OpenCode

OpenCode Integration

Drop this config into your project's opencode.json to use the Lumo API directly from OpenCode. Lumo is auto detected.

opencode.json
{
  "$schema": "https://opencode.ai/config.json",
  "provider": {
    "lumo": {
      "npm": "@ai-sdk/openai-compatible",
      "name": "Lumo",
      "options": {
        "baseURL": "https://api.carlostkd.ch/v1"
      },
      "models": {
        "auto": {
          "name": "Lumo Auto"
        }
      }
    }
  }
}
🔐
U2L Encryption Setup — To enable end-to-end encryption with OpenCode, you'll need to run the U2L proxy server and configure encrypted provider:
u2l-proxy.js
const http = require('http');
const https = require('https');
const crypto = require('crypto');
const { execSync } = require('child_process');
const fs = require('fs');

const PORT = process.env.PROXY_PORT || 3001;
const LUMO_API_URL = 'https://lumo.proton.me/api/ai/v1';

const LUMO_PUBLIC_KEY = `-----BEGIN PGP PUBLIC KEY BLOCK-----

xjMEaA9k7RYJKwYBBAHaRw8BAQdABaPA24xROahXs66iuekwPmdOpJbPE1a8A69r
siWP8rfNL1Byb3RvbiBMdW1vIChQcm9kIEtleSAwMDAyKSA8c3VwcG9ydEBwcm90
b24ubWU+wpkEExYKAEEWIQTwMqEWnd/47aco5ZqadMPvYVFKKgUCaA9k7QIbAwUJ
B4TOAAULCQgHAgIiAgYVCgkICwIEFgIDAQIeBwIXgAAKCRCadMPvYVFKKqiVAQD7
JNeudEXTaNMoQMkYjcutNwNAalwbLr5qe6N5rPogDQD/bA5KBWmDlvxVz7If6SBS
7Xzcvk8VMHYkBLKfh+bfUQzOOARoD2TtEgorBgEEAZdVAQUBAQdAnBIJoFt6Pxnp
RAJMHwhdCXaE+lwQFbKgwb6LCUFWvHYDAQgHwn4EGBYKACYWIQTwMqEWnd/47aco
5ZqadMPvYVFKKkuRAQChUthLyAccUD6UrJkroc6exHIMSR5Vlk4d4L8OeFUWWAEA
3ugyE/b/pSQ4WO+fiTkHN2ZeKlyjdZMbxO6yWPA5uQk=
=h/mc
-----END PGP PUBLIC KEY BLOCK-----`;

const LUMO_FINGERPRINT = 'F032A1169DDFF8EDA728E59A9A74C3EF61514A2A';

function generateUUID() {
  return crypto.randomUUID();
}

function generateRandomBytes(length) {
  return crypto.randomBytes(length);
}

function u2lInit() {
  const key = generateRandomBytes(32);
  const requestId = generateUUID();
  return { key, requestId };
}

function u2lEncrypt(plaintext, ctx) {
  const iv = generateRandomBytes(12);
  const aad = `lumo.request.${ctx.requestId}.turn`;
  
  const cipher = crypto.createCipheriv('aes-256-gcm', ctx.key, iv);
  cipher.setAAD(Buffer.from(aad));
  
  let encrypted = cipher.update(plaintext, 'utf8', 'base64');
  encrypted += cipher.final('base64');
  
  const tag = cipher.getAuthTag();
  
  const result = Buffer.concat([iv, Buffer.from(encrypted, 'base64'), tag]);
  return result.toString('base64');
}

function u2lDecrypt(ciphertextBase64, ctx) {
  const raw = Buffer.from(ciphertextBase64, 'base64');
  
  const iv = raw.slice(0, 12);
  const tag = raw.slice(-16);
  const encrypted = raw.slice(12, -16);
  
  const aad = `lumo.response.${ctx.requestId}.chunk`;
  
  const decipher = crypto.createDecipheriv('aes-256-gcm', ctx.key, iv);
  decipher.setAAD(Buffer.from(aad));
  decipher.setAuthTag(tag);
  
  let decrypted = decipher.update(encrypted);
  decrypted = Buffer.concat([decrypted, decipher.final()]);
  
  return decrypted.toString('utf8');
}

function gpgEncryptKey(rawKey, fingerprint = LUMO_FINGERPRINT) {
  const gnupgHome = process.env.GNUPGHOME || '/tmp/gpg-home-' + process.pid;
  
  try {
    execSync(`mkdir -p ${gnupgHome} && chmod 700 ${gnupgHome}`);
    
    const keyFile = `/tmp/lumo_pub_${process.pid}.asc`;
    const encryptedFile = `/tmp/encrypted_key_${process.pid}.bin`;
    const plainKeyFile = `/tmp/plain_key_${process.pid}.bin`;
    
    fs.writeFileSync(keyFile, LUMO_PUBLIC_KEY);
    fs.writeFileSync(plainKeyFile, rawKey);
    
    try {
      execSync(
        `gpg --batch --yes --homedir ${gnupgHome} --import ${keyFile} 2>/dev/null`
      );
      
      execSync(
        `gpg --batch --yes --homedir ${gnupgHome} --trust-model always --encrypt --recipient ${fingerprint} --output ${encryptedFile} ${plainKeyFile} 2>/dev/null`
      );
      
      const encryptedData = fs.readFileSync(encryptedFile);
      return encryptedData.toString('base64');
    } finally {
      try { fs.unlinkSync(keyFile); } catch {}
      try { fs.unlinkSync(encryptedFile); } catch {}
      try { fs.unlinkSync(plainKeyFile); } catch {}
    }
  } catch (err) {
    console.error('GPG encryption failed:', err.message);
    throw new Error('Failed to encrypt key with GPG');
  }
}

function encryptMessage(message, ctx) {
  if (typeof message.content === 'string') {
    return {
      ...message,
      content: u2lEncrypt(message.content, ctx),
      encrypted: true
    };
  }
  return message;
}

function encryptPayload(payload) {
  const ctx = u2lInit();
  const encryptedMessages = payload.messages.map(msg => encryptMessage(msg, ctx));
  const requestKey = gpgEncryptKey(ctx.key);
  
  return {
    ...payload,
    encrypted: true,
    lumo: {
      client_type: 'proxy',
      request_key: requestKey,
      request_id: ctx.requestId
    },
    messages: encryptedMessages
  };
}

// Server setup continues in the full file...

// Run: node u2l-proxy.js
// Then use baseURL: "http://localhost:3001/v1/chat/completions?encrypted=true"
opencode.json (Encrypted)
{
  "$schema": "https://opencode.ai/config.json",
  "provider": {
    "lumo-encrypted": {
      "npm": "@ai-sdk/openai-compatible",
      "name": "Lumo (U2L Encrypted)",
      "options": {
        "baseURL": "http://localhost:3001/v1/chat/completions?encrypted=true",
        "apiKey": "your_api_key"
      },
      "models": {
        "auto": {
          "name": "Lumo Max (Encrypted)"
        }
      }
    }
  },
  "server": {
    "port": 4096,
    "hostname": "0.0.0.0"
  }
}
Setup Steps:
  1. Install dependencies: npm install @ai-sdk/openai-compatible
  2. Start the proxy: node u2l-proxy.js (runs on port 3001)
  3. Ensure GPG is installed and accessible
  4. Configure OpenCode with the encrypted provider config above
  5. All messages will be encrypted client-side before reaching Lumo API
Models

Available API Models

Three models are available on the chat completions endpoint. The auto model is recommended for general use as it routes to the best backend model for the task.

auto
Auto-route
Lumo selects the optimal model based on task type, prompt complexity, and cost. Recommended for most use cases.
200k ctx chat analysis vision code
lumo-lite
Lumo Lite
Optimised for low-latency and high-throughput. Best for classification, short-form generation, and real-time applications.
266k ctx chat vision classification extraction
lumo-max
Lumo Max
Best-in-class reasoning for complex analytical tasks, multi-step code generation, and long-document synthesis. Produces a visible thinking block before the answer.
200k ctx reasoning analysis vision code
Request Schema

Supported Parameters

The endpoint is OpenAI chat completions compatible. Only parameters confirmed as working are listed here.

Parameter Type Required Description
model string required Model ID. Use auto, lumo-lite, or lumo-max.
messages array required Conversation history as {role, content} objects. Roles: system, user, assistant.
Create/List Keys array required Its possible to do this I do not intend to disclose this method publicly
temperature number optional Sampling temperature 0.0–2.0. Lower = more deterministic. Default: 1.0.
max_tokens integer optional Maximum tokens in the completion. Defaults to model output limit.
stream boolean optional Can be turned ON/OFF and is now required.
reasoning_effort string optional Controls model thinking. Valid values: "none" (fast, no thinking) and "high" (slow, ~2-3x latency with visible thinking block). Invalid values like "low", "medium" return HTTP 422. Default: "high".
Complete Example

Full Working Script

Production ready script with all confirmed working features. Switch models by commenting and uncommenting the MODEL lines. Extend MESSAGES for multi-turn conversations. Includes reasoning_effort, streaming, and all available tools.

python — lumo_chat.py
import requests
import json

API_KEY = "your_api_key_here"
# MODEL = "auto"
# MODEL = "lumo-max"
MODEL = "lumo-lite"

TEMPERATURE = 0.7
MAX_TOKENS = 1024
# "none" = fast (no thinking), "high" = slow (with thinking)
REASONING_EFFORT = "high"

# Tools must be in OpenAI function format. Simple string names may not work.
TOOLS = [
    {"type": "function", "function": {"name": "weather", "description": "Get weather data", "parameters": {"type": "object", "properties": {}, "required": []}}},
    {"type": "function", "function": {"name": "stock", "description": "Get stock price", "parameters": {"type": "object", "properties": {}, "required": []}}},
    {"type": "function", "function": {"name": "web_search", "description": "Search the web", "parameters": {"type": "object", "properties": {}, "required": []}}},
    {"type": "function", "function": {"name": "cryptocurrency", "description": "Get crypto prices", "parameters": {"type": "object", "properties": {}, "required": []}}},
    {"type": "function", "function": {"name": "proton_info", "description": "Get Proton product info", "parameters": {"type": "object", "properties": {}, "required": []}}},
    {"type": "function", "function": {"name": "generate_image", "description": "Generate an image", "parameters": {"type": "object", "properties": {}, "required": []}}},
    {"type": "function", "function": {"name": "describe_image", "description": "Describe an image", "parameters": {"type": "object", "properties": {}, "required": []}}},
    {"type": "function", "function": {"name": "edit_image", "description": "Edit an image", "parameters": {"type": "object", "properties": {}, "required": []}}},
    {"type": "function", "function": {"name": "web_extract", "description": "Extract content from web page", "parameters": {"type": "object", "properties": {}, "required": []}}}
]

SYSTEM_PROMPT = "You are a helpful assistant that gives concise and accurate answers."

MESSAGES = [
    {"role": "user", "content": "why is the sky blue?"},
]

url = "https://lumo-api.proton.me/api/ai/v1/chat/completions"
headers = {
    "Authorization": "Bearer " + API_KEY,
    "Content-Type": "application/json",
}
payload = {
    "model": MODEL,
    "messages": [{"role": "system", "content": SYSTEM_PROMPT}] + MESSAGES,
    "temperature": TEMPERATURE,
    "max_tokens": MAX_TOKENS,
    "reasoning_effort": REASONING_EFFORT,
    "stream": True,
    "tools": TOOLS,
}

r = requests.post(url, headers=headers, json=payload, timeout=120)
r.raise_for_status()

chunks = []
for line in r.iter_lines():
    if line and line.startswith(b"data:"):
        data = line[5:].decode('utf-8')
        if data == "[DONE]":
            break
        try:
            chunk = json.loads(data)
            choices = chunk.get("choices", [])
            if choices:
                delta = choices[0].get("delta", {})
                if isinstance(delta, dict):
                    content = delta.get("content")
                    if content:
                        chunks.append(content)
        except (json.JSONDecodeError, KeyError, TypeError):
            continue

full_content = "".join(chunks)

if "</think>" in full_content:
    full_content = full_content.split("</think>", 1)[1].strip()

print(full_content)
python — list_models.py
import requests
import json

API_KEY = "your_api_key_here"

url = "https://lumo-api.proton.me/api/ai/v1/models"
headers = {
    "Authorization": "Bearer " + API_KEY,
    "Content-Type": "application/json",
}

r = requests.get(url, headers=headers, timeout=30)
print("Status:", r.status_code)
print("Response:", json.dumps(r.json(), indent=2))
response — list_models.py output
Status: 200
Response: {
  "object": "list",
  "data": [
    {
      "object": "model",
      "id": "lumo-lite",
      "created": 1779890182,
      "owned_by": "proton",
      "capabilities": {
        "completion_chat": true,
        "function_calling": true,
        "vision": true
      },
      "name": "Lumo Lite",
      "description": "Fast, efficient model for everyday tasks.",
      "max_context_length": 131072,
      "aliases": [],
      "deprecation": null,
      "deprecation_replacement_model": null,
      "default_model_temperature": 1,
      "archived": false
    },
    {
      "object": "model",
      "id": "lumo-max",
      "created": 1779890182,
      "owned_by": "proton",
      "capabilities": {
        "completion_chat": true,
        "function_calling": true,
        "vision": true
      },
      "name": "Lumo Max",
      "description": "More capable model for complex reasoning and longer context.",
      "max_context_length": 131072,
      "aliases": [],
      "deprecation": null,
      "deprecation_replacement_model": null,
      "default_model_temperature": 1,
      "archived": false
    }
  ]
}
python — create_keys.py
Redacted: I do not intend to disclose this method publicly prior to its official launch
      
Native Endpoint

Real-Time Tools via /api/ai/v1/chat/completions

The native endpoint uses a proprietary Prompt wrapper format and supports real-time tools fully. This is separate from the OpenAI-compatible endpoint tools passed to /chat/completions are still ignored there.

Key discovery: Some tools only activate when the full tools array is passed. Passing ["web_search"] alone returns a "web search is OFF" response. Passing all five tools together activates whichever tool the model decides is appropriate for the query.

Available Tools

ToolStandaloneFull ArrayNotes
weatherReturns temperature, humidity, wind, pressure
stockReal-time stock price by ticker symbol
web_searchReturns full search results with sources
web_extractExtract content from web pages
cryptocurrencyReal-time crypto prices across exchanges
proton_info?Likely Proton product documentation
generate_imageGenerate images from text prompts
describe_imageAnalyze and describe uploaded images
edit_imageEdit existing images with text instructions
python — native endpoint with tools
import requests, json

ALL_TOOLS = ["proton_info", "web_search", "web_extract", "weather", "stock", "cryptocurrency",
             "generate_image", "describe_image", "edit_image"]

payload = {
    "Prompt": {
        "type": "generation_request",
        "turns": [
            {"role": "user", "content": "What is the weather in Geneva today?",
             "images": [], "encrypted": True}
        ],
        "options": {"tools": ALL_TOOLS},
        "targets": ["message"]
    }
}

r = requests.post(
    "https://lumo.proton.me/api/ai/v1/chat/completions",
    headers={
        "Authorization": "Bearer " + API_KEY,
        "Content-Type": "application/json",
    },
    json=payload, timeout=60, stream=True
)

message = ""
for raw in r.iter_lines():
    if not raw: continue
    line = raw.decode("utf-8").strip()
    if not line.startswith("data:"): continue
    try:
        chunk = json.loads(line[5:])
        if chunk.get("type") == "token_data" and chunk.get("target") == "message":
            message += chunk.get("content", "")
    except: continue

print(message.strip())

Stream Event Types

typetargetDescription
queuedRequest received by server
ingestingProcessing started. Contains job_id and model_name
token_datareasoningInternal thinking discard
token_datamessageResponse to display to user
token_datatitleAuto-generated conversation title
tool_callTool invoked. content is JSON with name and arguments
tool_resultTool response. content is JSON with real-time data
Known Issues

Limitations & Not Yet Working

The API is in early access. The frontend documentation config is ahead of what is actually deployed on the backend.

⚠️
No end-to-end encryption via API. The web interface encrypts message content client-side before sending. API calls use plain text over HTTPS only content is visible to Proton's backend infrastructure. The encrypted field is accepted for both values but ignored, some progress here looks good.
🪢
Stream parameter is now required. Omitting it returns a 400 error. Setting "stream": false now works correctly and returns a clean application/json response instead of SSE no chunk reassembly needed. Setting "stream": true returns text/event-stream as expected. Previously this parameter was ignored and SSE was always forced.
📄
Official docs published. but remains hidden from the majority of users...
🥶
Temperature is ignored. Values from -1.0 to 20.0 all return 200 with no validation error. Responses are identical regardless of the value passed. No observable effect on output determinism or randomness.

Lumo API Tracker — community tested · not affiliated with Proton · Live API

▶ STATUS BOARD
Status Board
LUMO API · DEPARTURES
LM-001Model Selection01DELAYED
LM-002Encrypted Field02LANDED
LM-003Web Search / Weather03LIVE▲ Changed 2026-09-11 14:00:16
LM-004Image Generation04LIVE▲ Changed 2026-09-09 22:00:19

LM-007 PGP Encryption 07 BOARDING
LM-008max_tokens param08LANDED▲ Changed 2026-09-11 03:00:20
LM-009temperature param09DELAYED
--:--:--
LAST CHECK2026-09-17 03:00:01
TOTAL CHECKS346
CHECK INTERVAL1H