Features Models Docs News Pricing Open the workspace
Documentation

Everything, in one page.

Eltes is a static-first, local-first agent workspace. No build step or demo replies; an optional Vercel/local backend supports key checks and provider proxying. This guide covers keys, providers, CORS, the tool loop, accounts and where every byte actually lives.

v3.18.0 25 minute read No telemetry BYOK only

Overview

Eltes is a browser-based AI workspace. Connect a real provider account (or paste a key), pick a model, and Eltes streams that provider's answer, renders tool calls, and keeps history in this browser. Direct mode calls providers from the browser; an optional separately hosted Vercel/local proxy can test keys and forward requests. If a hosted provider is not connected, the app says so instead of inventing an answer — and Eltes CAI, a small on-device helper, is available with no key.

Five ideas, in order

  1. Local-first. Chats, settings, keys and profiles live in localStorage. Clearing site data is the nuclear option, and it is yours to press.
  2. Bring your own key. By default, requests go from your tab to the provider. An optional trusted backend can relay a key for each request without persisting or logging it in the function code.
  3. Live web search. Toggle Web, ask for something current, and Eltes records the search steps, writes an answer with [1]-style citations and lists every source. No key, no result: an explicit message instead of a guess.
  4. Real agent loop. When tools are on, the model can call calculate, convert_units, define_term, current_time, search_history and web_search; every call is shown as a card with its arguments, result and runtime.
  5. On-device when you need it. Eltes CAI-1 answers chat, Eltes questions, model choice, arithmetic, units and dates in this tab. No key, no network, and it is explicit about what it does not know.

What changed in v3

Areav3 behaviour
RepliesLive streams only. A missing key produces a connect card; failures produce typed error cards with retry. CAI answers locally and labels itself.
ChatObsidian black shell (Graphite grey optional) with a block field that moves with the scroll. Step timeline, tool cards, per-message chips, first-token latency, tokens and tok/s.
Models66 real models across 14 providers, including Claude Sonnet 5.5, DeepSeek V4.1, GPT-4o, Gemini, NVIDIA NIM, Cohere, three Eltes CAI models and the Lab model you train in the browser.
AccountsSign up or in with OpenAI, Anthropic or DeepSeek, or keep a local profile. GitHub sign-up is gone.
BrandGreen cloud mark — one eye closed, one open — across the site, favicon, app icon and maskable manifest icon.
DownloadsA portable Windows package with eltes.bat, a Start-Eltes helper and the whole static site.
Static by design. Eltes is plain HTML, CSS and JavaScript. Host it on any static host, or open it from a local server. There is nothing to compile and no secrets in the bundle.

Quickstart

From a blank tab to a live answer, with one provider key.

Open the workspace

Go to app.html. No install and no sign-in. An account is optional and only adds a profile.

Connect a provider

Press Connect an account, choose OpenAI, Anthropic or DeepSeek, and save the key. Or open Settings → API keys. The pill in the top bar turns green when at least one key is present. No key yet? Pick Eltes CAI-1.

Pick a model

Use the model button or ⌘M. Models without a key show a needs key badge; star the ones you use most and they move to the top.

Send a message

Type and press Enter. Tokens stream in as they arrive; press the stop button to keep a partial answer. Metrics appear under the reply when it finishes.

terminal — run Eltes locally
# clone the repository
git clone https://github.com/gefrus112/eltes
cd eltes

# any static server works; there is no build step
python3 -m http.server 4173

# open http://localhost:4173
No key handy? Eltes CAI-1 runs in this tab with no key. For an open-weight model, run Ollama and point Eltes at http://localhost:11434/v1 under Custom endpoints.

Accounts

An Eltes account is a profile plus a set of provider connections. It stores a name, an email, the provider that created it and — when you connect one — a provider key. By default there is no password and no session cookie: the profile is written to eltes.account.v1 and provider keys live in eltes.settings.v1. The one email this flow produces is the welcome message, sent by your own server — see Welcome email.

Set config.js to a Supabase project and the form grows an email-and-password path: the account lives in that project, a session survives reloads, and the welcome letter arrives through your own Resend account. It is optional, additive, and the local profile stays one click away — see Cloud accounts.

Create or sign in

Open auth.html. Sign up with OpenAI, Anthropic or DeepSeek, or create a local profile with a display name and email. Signing in restores the profile already in this browser. A local profile’s email is a label, not a credential — it can receive a welcome message from your own server, nothing else. Choose Create a Supabase account instead when you want a real identity: it needs a password and can verify the address. See Cloud accounts.

ProviderWhat “connect” does on a static copyWhat it does with a host
OpenAIValidates the key against /v1/models, then stores it locallyRedirects to /openai/start and returns a profile
AnthropicValidates against /v1/models with the browser-access headerRedirects to /anthropic/start and returns a profile
DeepSeekSaves the key; verification is best-effort because CORS variesRedirects to /deepseek/start and returns a profile
Local profileName + email, stored in this browser onlySame — no hosted identity required

Hosted OAuth contract

A static page cannot hold a client secret, so the hosted path is a contract rather than a fake success state. Set the endpoint before the auth scripts load:

auth.html — before the scripts
<!-- Only when a real auth backend exists -->
<script>
  window.ELTES_CONFIG = {
    authEndpoint: 'https://auth.example.com'
  };
</script>

With authEndpoint set, a provider button redirects to:

redirect shape
# provider is openai, anthropic or deepseek
GET https://auth.example.com/<provider>/start
    ?return=<the full auth.html URL>
    &mode=signup

# your backend completes OAuth, then returns the user to:
https://eltes.example/auth.html?account=<base64url(profile)>

# profile
{
  "name": "Ada Lovelace",
  "email": "ada@example.com",
  "provider": "openai",
  "avatarUrl": "https://example.com/ada.png"
}

The page decodes the profile, writes it locally, clears the parameter and shows the signed-in state. Avatar URLs are restricted to https:. An error=… or provider_error=… parameter shows a failure message instead, so a cancelled login never looks successful. code and state are stripped from the address bar after the callback.

Never put a client secret in this page. Anything shipped to the browser is public. Without an endpoint, the connect panel asks for a real key and stores it locally — it does not claim an OAuth session.

Welcome email

When a local profile is created — on the auth page or in the quick sign-up on the landing page — Eltes sends a real welcome email — Welcome to Eltes AI Workspace. It is sent by the server on your own machine, through your own free provider account, at the moment you sign up. Eltes itself has no mail server and never sees your address.

Nothing is faked. With no provider configured, the message is written to .mail/outbox/ as a real .eml file and the panel says Saved to the local outbox. If a provider refuses the address, you see the refusal — not a green tick.

Free, no card

Three ways to deliver it, all with a free tier that is enough for personal use:

ProviderFree allowanceWhat you need
Resend3,000 emails/month · 100/dayAn API key and one verified domain. Fastest to wire up.
Brevo300 emails/dayAn API key and a verified sender address — no domain needed. Free mail carries a small Brevo footer.
Any SMTP mailboxWhatever your mailbox allowsELTES_SMTP_URL. Gmail needs an app password; most hosts give you an smtps:// endpoint.

Turn it on

one provider is enough
# Resend (recommended: 3,000/month free)
export ELTES_MAIL_FROM="Eltes <hello@yourdomain.com>"
export RESEND_API_KEY=re_…

# or Brevo (300/day free, no domain required)
export ELTES_MAIL_FROM="Eltes <you@gmail.com>"
export BREVO_API_KEY=xkeysib-…

# or your own SMTP mailbox
export ELTES_MAIL_FROM="Eltes <you@example.com>"
export ELTES_SMTP_URL="smtps://you@app-password@smtp.example.com:465"

npm start   # the welcome email now leaves the machine
VariableWhat it does
ELTES_MAIL_FROMRequired for delivery. Name <address> or a bare address; the address must be verified by the provider.
RESEND_API_KEY · BREVO_API_KEYPick one provider. Resend wins if both are set.
ELTES_SMTP_URLsmtps://user:pass@host:465 for implicit TLS, smtp://…:587 upgrades with STARTTLS when the server offers it. Add ?tls=off only for a local relay.
ELTES_MAIL_PROVIDERresend, brevo, smtp or none to force one and ignore the rest.
ELTES_MAIL_DIRWhere the outbox, events and suppression list live. Defaults to .mail/; the desktop app uses mail/ inside its data folder.
ELTES_MAIL_WEBHOOK_SECRETRequired when the webhook is reachable from another machine. Without it, only loopback calls are accepted.
ELTES_MAIL_REPLY_TOOptional Reply-To for the welcome email.

What actually happens

  1. The form posts to POST /api/email/welcome on the same origin, with the address and display name.
  2. The server validates the address, checks the suppression list, and renders the multipart message — plain text and HTML, no tracking pixel.
  3. It calls your provider: api.resend.com/emails, api.brevo.com/v3/smtp/email, or the SMTP server. The response carries the provider's own id, or its error.
  4. Everything is recorded in .mail/events.jsonl and shown by GET /api/email/status.

Bounces, honestly

A bounce is handled where it is visible, and a hard bounce is remembered:

  • SMTP: a 5xx reply to RCPT TO or DATA is a hard bounce in the same request. The address goes on the suppression list immediately.
  • Provider APIs: a 4xx that names the recipient is treated the same way; anything else is an error, not a bounce.
  • Webhooks: point Brevo at POST /api/email/webhook?provider=brevo, or Resend at the same URL. Both payload shapes are normalised — hard_bounce and email.bounced become one event.
  • Suppression: hard bounces and complaints are written to .mail/suppressed.json. Later sends to that address short-circuit before any network call. node tools/mail.mjs unsuppress you@example.com lifts it.

Delivery of the webhook needs a public URL. Locally, the SMTP and API paths already report bounces synchronously; the webhook is for a deployment that faces the internet.

The command line

node tools/mail.mjs
npm run mail status                       # provider, folder, counts, recent events
npm run mail welcome you@example.com      # the real welcome email
npm run mail test you@example.com         # prove a provider works
npm run mail bounce you@example.com       # record a hard bounce (same event a webhook writes)
npm run mail unsuppress you@example.com   # let that address receive mail again

On the static site

The hosted copy on GitHub Pages has no Node server of its own, so it takes the other route: a Supabase account plus an Edge Function that calls Resend. That is the path used when config.js names a project — the same Welcome to Eltes AI Workspace letter, the same honesty about failures.

Where the Node server does run — npm start, or the desktop app, which keeps its mail folder at %APPDATA%\Eltes\mail (macOS: ~/Library/Application Support/Eltes/mail) — the local route is used and no Supabase project is needed. Click Use a local profile instead on the auth page to stay offline even when a project is configured.

Addresses are stored only where they are needed: the profile in this browser's localStorage, the address and timestamps in the events file next to the outbox, or public.email_events in your own Postgres. There is no analytics, no open tracking and no shared list.

Cloud accounts

The sign-up form can create a real account in your own Supabase project and keep working on a static host. There is no Eltes account server in the middle: the browser talks to your project's Auth API, the project stores the profile, and an Edge Function in the same project sends the welcome email through Resend. The publishable key in config.js is the only credential the site ships — row-level security decides what it may touch.

Local still works. With no config.js, or after clicking Use a local profile instead, everything falls back to the browser profile and the Node/desktop mail route. The desktop app is unchanged and never needs a network account.

What the account does

FeatureHowWhat you see if it fails
Sign up with email + passwordPOST /auth/v1/signupThe exact Supabase message — weak password, address in use, rate limit
Welcome emailEdge Function eltes-mail → ResendNot sent — Supabase has no RESEND_API_KEY yet, or the provider's own error
Verify the addressOptional. With confirmation on, the welcome letter's single-use link is the confirmation; with it off, sign-up works immediatelySign-in says confirm the address first
Sign inPOST /auth/v1/token?grant_type=passwordThat email and password do not match an account.
Magic linkFunction builds the link, Resend delivers itSign-in link sent — only after Resend accepted the message
Password resetadmin/generate_link type recoveryReset link sent, then a new-password form
Session restoreRefresh token in eltes.cloud.v1Signed out silently when the refresh token is gone
Sign outPOST /auth/v1/logoutLocal mirror cleared even when the call fails

The pieces

  • config.js — project URL, publishable key, function name. Public by design; delete it to disable cloud accounts.
  • assets/js/supabase.js — the client. Plain fetch, no SDK, no build step; ~400 lines.
  • supabase/migrations/0001_eltes_accounts.sql — profiles, email_events, suppressed_emails, the auth.users trigger, RLS policies and the mail rate-limit function.
  • supabase/functions/eltes-mail/index.ts — one endpoint for welcome, magic link and reset. Builds the link with the service role, sends with Resend, records the outcome.
  • supabase/functions/email-webhook/index.ts — Resend's Svix-signed webhook; hard bounces and complaints land in suppressed_emails.
  • supabase/functions/site-publish/index.ts — copies the published site into the project's public Storage bucket, so the same files are also served from Supabase.

Wire it up

Supabase CLI — five commands
# 1. point the folder at your project
npx supabase link --project-ref your-project-ref

# 2. tables, trigger, policies, rate limits
npx supabase db push

# 3. the two functions (they check their own auth)
npx supabase functions deploy eltes-mail --no-verify-jwt
npx supabase functions deploy email-webhook --no-verify-jwt

# 4. the only two secrets that matter
npx supabase secrets set RESEND_API_KEY=re_… \
  ELTES_MAIL_FROM="Eltes <hello@yourdomain.com>" \
  ELTES_SITE_URL=https://gefrus112.github.io/eltes/

# 5. tell Auth where links may land
#    Authentication → URL Configuration → Site URL + Redirect URLs
#    https://gefrus112.github.io/eltes/**

Then put the same URL and key in config.js and submit the sign-up form. There is nothing to build: GitHub Pages serves the files as they are.

About confirmation. The reference project has Confirm email switched off, so an account works the moment it is created — before a Resend key exists. Turn it on at Authentication → Providers → Email and the welcome letter's link becomes the confirmation: it verifies the address, then lands on auth.html signed in. Either way the same letter is the first thing the inbox receives.

A second home: the Storage mirror

The site is published twice from the same files. GitHub Pages is the canonical address; the Supabase project serves an identical copy from a public Storage bucket on its CDN:

HomeAddressBuilt by
GitHub Pagesgefrus112.github.io/eltes/the Pages workflow, on every push to main
Supabase Storage…/storage/v1/object/public/eltes/index.htmlsite-publish, on demand

Supabase has no static-site product; this is a public Storage bucket, not a custom domain. Each object is uploaded with its real content type, so the browser renders the pages and resolves assets/… exactly as it does on Pages. Only pages, assets/ and downloads/ are published — never the SQL, the tests or the desktop harness.

refresh the mirror
ELTES_PUBLISH_TOKEN=… npm run site:publish -- --dry  # 62 files, 2.8 MB — nothing sent
ELTES_PUBLISH_TOKEN=… npm run site:publish          # upsert, then print the URL

The token lives in Edge Function secrets as ELTES_PUBLISH_TOKEN; without it the function answers 503 publish_token_not_configured, and with the wrong one 401 bad_token. Publishing reads the repository through the GitHub API, so a push to main is enough before you re-run it.

Free, and honest about it

  • Supabase free plan — two active projects, 500,000 Edge Function invocations a month, 50,000 monthly active users. A project that stays idle for about a week is paused; one click in the dashboard resumes it.
  • Resend free plan — 3,000 messages a month, 100 a day, no card. Without a verified domain the sandbox sender onboarding@resend.dev only delivers to the address that owns the Resend account; verify a domain (or use Brevo's 300/day for any recipient) before inviting other people.
  • Supabase's own mailer is not used. It is limited to a couple of messages an hour project-wide, and its default sender only reaches project members. Every letter here goes through your Resend account instead.
  • Nothing is faked. No key means no send, and the panel says exactly that. The database refuses a second welcome for the same account (a partial unique index), the function refuses more than six messages per network or three per address every ten minutes, and a bounced address is skipped before any provider call.

What stays private

The service_role key and the Resend key exist only as Edge Function secrets inside your project. They are never in config.js, never in the HTML, never in this repository — the test suite fails if either ever appears. The browser holds a publishable key that can only read and update the signed-in user's own profile row after RLS has had its say.

API keys

Keys are read from localStorage at request time and are excluded from exports. In direct mode, OpenAI-compatible providers receive Authorization: Bearer …; Anthropic receives x-api-key and anthropic-version. With the optional proxy, the browser sends the key in a request header to the configured backend, which forwards it using the provider's required authentication. Eltes function code does not persist or log it; the key still transits the backend platform.

ProviderKey prefixWhere to get it
Anthropicsk-ant-…console.anthropic.com → API keys
OpenAIsk-proj-…platform.openai.com → API keys
GoogleAIza…aistudio.google.com → API key
Z.ai · GLMaccount tokenz.ai → API keys
DeepSeeksk-…platform.deepseek.com
xAIxai-…console.x.ai
Qwen · Alibabask-…Model Studio console
MistralAPI keyconsole.mistral.ai
Groqgsk_…console.groq.com
OpenRoutersk-or-v1-…openrouter.ai/keys
NVIDIA NIMnvapi-…build.nvidia.com

What a request looks like

OpenAI-compatible streaming
POST {baseUrl}/chat/completions
Authorization: Bearer <your key>
Content-Type: application/json

{
  "model": "gpt-4.1",
  "stream": true,
  "messages": [{ "role": "user", "content": "Hello" }]
}

Rotating and removing keys

  • Keys can be replaced at any time under Settings → API keys; saving overwrites the old value immediately.
  • Removing a key disables that provider's models until a new one is saved.
  • Exports never include keys. Backups are safe to share; localStorage is not.
Least privilege. Use a dedicated key per device, set a spend limit at the provider, and revoke it from the provider dashboard if a machine is shared.

Providers & CORS

By default, providers are called directly from the page, which requires permissive provider CORS headers. Some providers allow it; others do not. If a direct call is blocked, Eltes shows a network/CORS error with retry guidance. You may instead configure the optional Vercel/local backend to relay supported hosted-model requests; review the proxy trust and privacy notes before using it.

ProviderBase URLDirect browser calls
Anthropicapi.anthropic.com/v1Yes — sends anthropic-dangerous-direct-browser-access
OpenAIapi.openai.com/v1Yes
Google Geminigenerativelanguage.googleapis.com/v1beta/openaiYes
Z.ai · GLMapi.z.ai/api/paas/v4Usually
DeepSeekapi.deepseek.com/v1Varies by region
xAIapi.x.ai/v1Varies
Qwendashscope-intl.aliyuncs.com/compatible-mode/v1Varies
Mistralapi.mistral.ai/v1Varies
Groqapi.groq.com/openai/v1Yes
OpenRouteropenrouter.ai/api/v1Yes
NVIDIA NIMintegrate.api.nvidia.com/v1Yes
Eltes CAIin this tabNot applicable — no network request
Customyour URLDepends on your server

Error states you will actually see

StateWhat Eltes showsWhat to do
401 / 403“Provider rejected that key”, with Fix keyPaste a fresh key; the failed turn retries automatically after saving
429Rate limit or quota reachedWait, retry, or switch to another connected model
Network / CORS“Could not reach provider”Check the network, try another provider, or route through a proxy
404Model not available on this accountPick another model from the picker
No keyConnect card with the provider rowAdd a key or switch models — no fake answer is generated
Offline“You are offline”Reconnect and press retry
Optional proxy. Eltes can send a key to a Vercel/local backend over HTTPS for one request; the function code does not persist or log it. The backend operator and platform still process it, so configure only a backend you trust. Direct browser-to-provider mode remains available when the backend URL is blank. See deployment steps.

Custom endpoints

Anything that speaks OpenAI's /chat/completions can back Eltes: Ollama, vLLM, LiteLLM, LM Studio, a Cloudflare Worker or your team gateway. Set a base URL, a model id and — only if your server requires one — a token.

  1. Open Settings → API keys → Custom endpoint.
  2. Enter the base URL, including /v1 when that is what your server expects.
  3. Enter the model id exactly as the server reports it.
  4. Pick Custom endpoint from the model picker and send a message.
Ollama — OpenAI-compatible
# 1. run a model
ollama run llama3.1

# 2. in Eltes → Settings → Custom endpoint
Base URL   http://localhost:11434/v1
Model id   llama3.1

# Ollama allows browser origins from localhost by default.
# For another device, set OLLAMA_ORIGINS to your static host.
OLLAMA_ORIGINS="https://eltes.example" ollama serve

When CORS blocks the browser

Configure the optional Vercel backend described in Self-hosting, or run Eltes with npm start for the local API during development. The proxy forwards the user's key for that request and only allows fixed provider targets. Do not paste a provider key into a source file, public static host, or shared deployment environment variable.

HTTPS pages cannot call HTTP endpoints. If Eltes is served over HTTPS, a plain http://localhost endpoint will be blocked by mixed-content rules. Serve Eltes locally for local models, or expose the model over HTTPS.

Models

HTML code fences include a sandboxed preview, copy and download actions. Preview scripts run in an isolated frame with network access blocked.

NVIDIA NIM uses https://integrate.api.nvidia.com/v1 and a key from NVIDIA Build. Catalog access and any free quota depend on NVIDIA's account and model availability; these models are not guaranteed to be free.

The catalogue ships 66 models across 14 providers. Each hosted entry is a real API target with its own context window, tier and capability flags. Nothing is simulated, so a hosted model only answers when its provider is connected. The three Eltes CAI entries run locally instead, and Eltes Lab-1 is the character model you train yourself in Lab.

ProviderModelsBest at
Eltes CAICAI-1, CAI-1 Fast, CAI DocsOn-device chat, maths, units, bases, dates, random and text tools
OpenAIGPT‑6 Sol/Luna, GPT‑4.1, GPT‑4o/mini, o1/mini, o3-miniReasoning, coding and vision
AnthropicClaude 5.5, 4.5, Sonnet 4, Haiku 4, Fable 5.1Code, long context, careful writing
DeepSeekV4.1 Flash, V4 Pro, Chat, ReasonerCost, 1M context, multimodal and reasoning
GoogleGemini 2.5 Pro/Flash, 2.0 Flash, 1.5 Pro1M–2M context, multimodal
Z.aiGLM-4.7, 4.6, 4.5, 4.5 AirAgentic coding, low cost
xAIGrok 4/3, 4 Fast, 3 MiniDirect answers, reasoning
QwenQwen3 Max/Coder, Qwen2.5 32B, Qwen3 30B A3BBilingual, repository-scale code
MistralMistral Large/Small, Codestral, Pixtral LargeFunction calling, vision, completion
GroqLlama 3.3 70B, 4 Scout, 3.1 8B, Qwen3 32BVery high tokens per second
NVIDIA NIMLlama 3.3, Nemotron Ultra, DeepSeek R1, Mixtral 8×22BOpen models via NVIDIA NIM; key and model access required
CohereCommand R+, Command ARAG, tool use, multilingual
OpenRouterAuto, Claude 5.5/3.5, Gemini 2.5/2.0, GLM-4.6, DeepSeek, Qwen, Llama 4One key, routing and fallbacks
CustomWhatever your endpoint servesLocal and private models

Eltes CAI — our own models

CAI (conversational agent intelligence) is a deterministic JavaScript rules-and-knowledge system implemented in assets/js/cai.js, not a trained neural network. It runs inside the tab with no key and no network. It can still make mistakes, so treat its scope and fallback as limits—not a guarantee of correctness.

ModelStrengthExample
Eltes CAI-1Bounded on-device chat, calculator and simple equation solving“Which model for code?” or “solve x² − 5x + 6 = 0”
Eltes CAI-1 FastShorter answers, quicker pacing“Convert 68 °F to °C”
Eltes CAI DocsEltes setup, keys, accounts, downloads“How do I connect a DeepSeek key?”

CAI covers Eltes documentation, model recommendations from the catalogue, arithmetic, percentages, common calculator functions (including roots, powers, trigonometry, logs, median, sum and product phrasing), one-variable linear and quadratic equations, unit and temperature conversion (including Kelvin and time units: ms → years), date arithmetic (days between dates, date + N days, days until a date), the current date and time, word counts and a curated glossary of AI terms (now 50+ terms including RAG, embeddings, attention and fine-tuning). Trigonometric inputs use radians. It is not a computer algebra system and cannot solve arbitrary proofs, higher-degree equations or every math problem. For chat understanding, it can retrieve exact matching excerpts and recap user prompts included in the active conversation context; it cannot search every saved chat from this local turn. It has no arbitrary-corpus import or training pipeline. Unsupported or ambiguous questions should receive a fallback, but this is not a guarantee against mistakes.

A tokenizer vocabulary is a set of text pieces, not a count of verified facts. Adding four million words would not by itself teach the assistant meanings or make its answers reliable. Knowledge expansion needs vetted source documents, retrieval with traceable references, abstention when evidence is weak, and regression tests.

Real model streaming, including Claude

Eltes streams from provider APIs when Settings → General → Stream responses is enabled. Claude uses Anthropic’s native Messages API; OpenAI-compatible providers stream chat-completion events. Replies are never simulated for hosted models.

  • Save a valid provider API key in Settings → API keys. Eltes checks locally for a key before sending and opens a connect prompt if it is missing.
  • Claude Sonnet 5.5 uses Anthropic SSE and paints tokens as they arrive. OpenAI-compatible providers use SSE with a live cursor and first-token metrics.
  • 401/403, 429, model-not-found and CORS/network failures have separate retry guidance.
  • Eltes does not include hosted model credits or determine whether a provider account has free quota. CAI is the free, on-device option; hosted usage follows provider terms and billing.

Per-chat models

The model is stored on the conversation, not globally. Switch mid-thread and the next reply uses the new model while the history stays intact. New chats use the default model chosen in Settings → Models. Deep links such as app.html?model=glm-4-6 preselect a model from the landing page.

No silent fallbacks. If a hosted model needs a key, Eltes shows a connect card naming the provider. It does not quietly answer with something else.

Agent & tools

Tools are real function calls, not a scripted animation. Turn on Tools in the composer (or set it as the default in Settings) and the model may call one of six tools. Five run locally; web_search uses your own search key through the optional backend. Eltes executes the call, returns the result to the model, and lets it continue — up to four rounds.

ToolInputWhat it does
calculate{ expression }Evaluates arithmetic and scientific functions (sqrt, sin, log, median, sum…) in a sandboxed parser — much richer than a four-function calculator
current_time{ timezone? }Returns the current date and time, optionally in an IANA timezone
search_history{ query }Searches saved conversations locally and returns titles plus snippets
convert_units{ value, from, to }Converts between length, mass, volume, data, speed, area, time and temperature using deterministic factors — Kelvin and time units included
define_term{ term }Defines an AI/Eltes term from the on-device glossary (RAG, embedding, transformer, etc.) with a curated, source-traceable explanation
web_search{ query, count? }Searches the live web through Brave, Tavily, Serper or Exa and returns numbered titles, URLs and excerpts for inline [1]-style citations. Only offered when the Web chip is on and a search key is saved

What the timeline shows

  • Thinking while the request is open, then streaming once tokens arrive.
  • Tool cards with the tool name, arguments, a running/done state and the runtime in milliseconds.
  • Metrics after the turn: first-token latency, total time, tokens in/out and tokens per second.
  • Model and provider chips on every assistant message, so a long thread with switches stays readable.
  • Source cards under web-search replies: numbered links to the pages the search provider returned, with the snippet it supplied.

Tool calling support

Tools are only offered when the selected model advertises support. Models without tool support — the Eltes CAI family and completion models such as Codestral — are sent the plain conversation. If a provider rejects the tool schema, the error card reports it and retry without tools still works.

Nothing leaves the page for tools. Arithmetic, time and history search run locally in JavaScript. Only the model request and its tool results go to the provider.

Chat commands

Type / in the composer to open the autocomplete menu. Commands run on-device, need no API key, and work with any selected model. Arrow keys move through suggestions, Tab or Enter completes, and Esc closes the menu.

CommandWhat it doesExample
/helpLists every command/help
/clearClears the current conversation/clear
/exportOpens the Markdown/JSON export dialog/export
/modelSwitches model by id or name/model gpt-4o
/calcEvaluates an expression with the safe parser/calc sqrt(81) + 2*3
/convertConverts units and temperatures/convert 5 mi to km
/defineLooks up the on-device AI glossary/define RAG
/baseConverts hex, binary, octal and decimal/base 255 to hex
/webSearches the live web and lists sources (needs a search key)/web DeepSeek V4 release notes
/timeShows device date, time and timezone/time
/coin, /diceRandom utilities using the device RNG/dice 20
/randomRandom integer in a range/random 1 100
/uppercase, /reverse, /sortText transforms/reverse "eltes"

CAI also understands the same jobs in plain language — “255 to hex”, “convert 60 minutes to hours”, “flip a coin”, “uppercase "hello"” or “sort banana apple cherry” — so the slash menu is a shortcut, not a separate mode.

Chats & export

Conversations are grouped into Today, Yesterday, Previous 7 days, Previous 30 days and Older. Pinned chats sit on top. Every row has a menu with rename, pin, clear, export and delete.

Editing and retrying

  • Edit a user message to rewind the thread to that point and put the text back in the composer.
  • Retry re-runs the last turn with the current model; the failed turn is kept out of the history.
  • Copy works on replies and on each code block independently.
  • Feedback thumb buttons are stored locally and never uploaded.

Exports

FormatContainsUse it for
MarkdownFull transcript with model, tool runtimes and token countsDocs, issues, sharing in a repo
JSON (one chat)Raw conversation objectDebugging or your own tooling
JSON (all chats)Chats and settings, keys strippedBackups and moving devices
Backups never include keys. Import merges chats and settings into this browser while keeping the keys already there.

Workflow: build a website

This is the loop Eltes is built around, end to end: open a workspace, start a terminal, create files, watch the page update while you edit, then open the finished page on its own. Studio's Guide button walks the same five steps and ticks each one off as you do it.

Follow it in the app. Open Studio and press Guide in the top bar. Each step has one button that performs the real action — create the files, run the command, open the preview — and the counter shows progress. Reset it from the same panel.
  1. 1

    Open the workspace

    Start the local server and open Studio. You get four panes: files on the left, the editor in the middle, the live preview on the right and the terminal underneath.

    npm install
    npm start
    # app       http://localhost:4173/app.html
    # studio    http://localhost:4173/studio.html

    The first run creates a small starter project in .studio/: index.html, styles.css, app.js, notes.md and README.txt.

  2. 2

    Start the terminal

    Click the terminal input, or press Run command in the Guide, which runs the starter build for you. The panel is a real shell in .studio/, so anything you would type in a Linux terminal works here — output streams while the command runs.

    pwd                       # /…/eltes/.studio
    ls -la                    # the files you can see in the tree
    npm run build             # runs build.mjs → dist/
    ls dist                   # index.html, about.html, styles.css
    node -v                   # the runtime your build uses

    The starter package.json points build at build.mjs, a ~15-line Node script that copies every HTML, CSS and JS file into dist/. No dependencies and no network — swap it for Vite, Eleventy or anything else whenever you want. ↑/↓ recall history, Ctrl+C stops a running command, Ctrl+L clears. Commands time out after 60 seconds by default and output is capped at 512 KB.

  3. 3

    Create the files

    Use + in the file panel, the Guide's New file, or write them from the terminal. index.html, about.html and a shared styles.css are enough for a first site — the Guide's Create files button scaffolds exactly that, with working links between the pages, a package manifest and a build script.

    # from the file panel:  +  →  pricing.html
    # or from the terminal:
    cat > about.html <<'EOF'
    <!doctype html>
    <link rel="stylesheet" href="styles.css">
    <h1>About</h1>
    <a href="index.html">Home</a>
    EOF

    Nested paths such as pages/about.html work too. Every new HTML file can be previewed; the editor highlights the open file and marks unsaved changes with a dot.

  4. 4

    Preview while you edit

    The right pane follows whichever HTML file you open, served from /preview/, so relative CSS, scripts, modules, fonts and images resolve like a normal site. Saving reloads it — and so does a build run in the terminal.

    • Press Ctrl/⌘+S to save, or stop typing for a second and Studio saves for you.
    • Switch the preview between desktop, tablet and phone widths.
    • Type any path in the preview address bar, for example /preview/about.html or /preview/dist/index.html.
    • Use the reload button after a change made outside the editor.
  5. 5

    Open the page on its own

    The pop-out button opens the current preview in a new browser tab, at its real URL. That is the same page a visitor would load — useful for testing links between pages, browser back and forward, and full-window layout.

    http://localhost:4173/preview/index.html
    http://localhost:4173/preview/about.html

    On a static deployment without the local server, Studio switches to browser-only mode and opens the page from a blob URL instead. The editing and preview loop is the same; the terminal is not.

  6. 6

    Build, then preview the build

    This is where the terminal earns its place. The starter ships a dependency-free build.mjs, so the build works immediately; point the preview at the output folder — no second server needed.

    npm run build              # build.mjs → dist/
    # then set the preview address to /preview/dist/index.html
    # or click "Run build" — Studio reads package.json and runs its build script
    # swapping in a real toolchain later is just:
    npm install --save-dev vite
    npx vite build             # writes dist/ in Vite's format

    The watcher reloads the preview whenever the build changes files. A failing build leaves its stderr in the terminal with the exit code, so you can fix it and run again. dist/ is hidden from the file tree to keep it readable, but it is served at /preview/dist/… like any other file.

At a glance

StepWhereWhat you see
Open the workspaceStudioFiles, editor, preview, terminal
Start the terminalTerminal panelStreaming output and exit status
Create filesFile panel or terminalNew files in the tree, opened in a tab
PreviewRight paneThe page loaded from /preview/
Open the pagePop-out buttonThe same page in its own tab
BuildTerminal or Run builddist/ output, preview reloaded

When a step does not work

  • The terminal says it needs npm start. Studio is open as a static page. Start the local server and reload; the editor and browser-only preview still work without it.
  • Nothing previews. Check the address bar path and that the file exists in the tree. A 404 page tells you the path it tried.
  • The preview is stale. Press the reload button; a file outside .studio/ is not watched.
  • npm init complains about the folder name. The workspace is a dot-folder (.studio), which npm will not accept as a package name. The starter package.json already has a valid name, so use npm run build and add scripts with npm pkg set instead.
  • A command was stopped. The 60-second limit fired or Ctrl+C was pressed. Long builds can be given more time with ELTES_STUDIO_TIMEOUT (milliseconds) when starting the server.

Studio, files and the terminal

Eltes Studio is a coding workspace served next to the app: a file list, an editor, a live preview and a real shell in one window. It is a normal page — open studio.html — and it works in two modes.

The workspace

Files live in .studio/ beside the app and are served at /preview/, so the page you are editing loads exactly like a normal site: relative stylesheets, scripts, modules, fonts and images all resolve. The first run creates index.html, styles.css, app.js, notes.md and README.txt.

  • Any HTML file previews. Create about.html, open it, and the preview pane follows it. The address bar above the preview accepts any path, including one produced by a build such as dist/index.html.
  • Auto-save and auto-reload. Edits save about a second after you stop typing, and the preview refreshes. Files changed from the terminal — npm run build, a formatter, a generator — reload the preview too.
  • Export. Export packages the workspace as a zip, entirely in the browser.
  • Device widths. The preview switches between desktop, tablet and phone, and can open in its own tab.

The terminal

The bottom panel runs a real shell (/bin/bash when available, otherwise /bin/sh) with the workspace as its working directory. Output streams as it is produced, so a long build is visible while it runs. Commands have a 60-second limit by default, output is capped at 512 KB per command, and Ctrl+C stops a running command.

ls -la
npm init -y
npm install --save-dev vite
npm run build
# then preview dist/index.html

cd is tracked in the panel, ↑/↓ walk command history, Ctrl+L clears, and the quick buttons run common commands. Run build reads the workspace package.json and runs its build script when one exists.

Local only, on purpose. The terminal is part of npm start, not part of the static site, so a Vercel or GitHub Pages deployment can never run commands. Requests need a per-process token, every path is confined to .studio/, and the shell refuses to start if the workspace cannot be resolved. Keep the local server on a trusted network. ELTES_STUDIO=0 disables Studio, ELTES_STUDIO_SHELL=0 disables only the shell.

Without a backend

Opened as a plain static page, Studio falls back to a browser-only project stored in localStorage. The editor, file creation, preview and zip export still work: the preview is assembled in the browser with local CSS and JavaScript inlined. The terminal explains that commands need npm start instead of pretending to run.

Shortcuts

KeyAction
Ctrl/⌘+SSave the open file
Tab / Shift+TabIndent / dedent
Ctrl/⌘+PSwitch to the next file
↑ in the terminalPrevious command
Ctrl+CStop the running command

Images & effects from code

A site does not need a folder of photographs to look designed. Gradients, grain, particles, halftone, glow and poster art can all be drawn by the browser from a few lines of CSS, SVG or canvas. The page is the image: there is no asset to export, nothing to compress, and the result stays sharp on any screen.

Studio ships six working recipes and one button that writes them into your project, so you can read the code, change a number, and watch the preview redraw.

Try it in Studio. Open Studio, press Effects in the top bar, then Create & preview on any card. Studio writes effects/<name>.html, opens it in the editor and points the preview at it — the same file you would ship.

Why draw with code

  • Small and sharp. A full-screen mesh gradient is about 2 KB of CSS instead of a 400 KB JPEG, and it stays crisp at every device pixel ratio.
  • Editable. Colours are hex values and shape is maths — change one variable and the whole page follows, including dark and light themes.
  • Responsive by construction. Percentages and vmax units re-compose the artwork at any window size; nothing is cropped or stretched.
  • No licences, no stock photos, no attribution. The output is generated from your own source files.

The trade-off is paint cost. Blurred layers and SVG filters are expensive per pixel, so the recipes keep the expensive parts on a few large elements, animate only transform and opacity, and stop when the reader asks for reduced motion.

Four techniques, one page

TechniqueBest forWhat to watch
CSS gradients and shadowsMesh fields, aurora ribbons, glows, duotone washesAnimate transform/opacity, not gradient stops — stop changes repaint the layer
SVG filtersGrain, turbulence, displacement, poster warp, logo effectsfeTurbulence is computed per pixel; keep filtered areas small or static
Masks, clip paths and blend modesHalftone dots, cut-outs, type blended into a fieldNeeds a stacking context (isolation: isolate) to blend against the right layer
Canvas 2DHundreds of moving particles, links, pointer-reactive fieldsScale for devicePixelRatio once, cap it at 2, and pause when the tab is hidden

The loop: create, preview, keep

Create the file

Press Effects → Create & preview. The recipe is written to effects/<name>.html and opened in the editor. Prefer the keyboard? + in the file panel creates the same file by hand.

Edit the code

Change a colour, a blur radius or the number of particles. Ctrl/⌘+S saves; Studio also auto-saves about a second after you stop typing.

Watch the preview

The right pane reloads the saved file from /preview/effects/<name>.html. Pop it out to see it at full window size, or switch to phone width to check the layout.

Use it in the site

Copy the CSS block, the SVG or the canvas script into your own page and stylesheet. Then build:

terminal — build the site with the effect
# the scaffold's build.mjs walks subfolders and copies .html/.css/.js/.svg/.json
npm run build
find dist -name '*.html'       # dist/index.html, dist/effects/mesh.html
# then preview the build itself
# /preview/dist/effects/mesh.html

Recipe 1 — a mesh gradient in CSS

Four soft circles, pushed through a blur and mixed with screen, read as a single continuous colour field. The grain layer on top hides banding, which is the giveaway of a low-bit-depth gradient.

effects/mesh.html — structure and style
<div class="mesh" aria-hidden="true">
  <span></span><span></span><span></span><span></span>
</div>

<style>
  .mesh { position: fixed; inset: -20%; filter: blur(48px) saturate(130%); }
  .mesh span {
    position: absolute; width: 62vmax; height: 62vmax; border-radius: 50%;
    mix-blend-mode: screen;
    animation: drift 22s ease-in-out infinite alternate;
  }
  .mesh span:nth-child(1) { background: #2f6bff; top: -10%; left: -6%; }
  .mesh span:nth-child(2) { background: #8b5cf6; top: 8%; right: -12%; animation-duration: 27s; }
  .mesh span:nth-child(3) { background: #12b981; bottom: -16%; left: 16%; animation-duration: 31s; }
  .mesh span:nth-child(4) { background: #f97362; bottom: -8%; right: 4%; animation-duration: 25s; }

  @keyframes drift {
    from { transform: translate3d(-3%, -2%, 0) scale(1); }
    to   { transform: translate3d(5%, 4%, 0) scale(1.18); }
  }
</style>

inset: -20% gives the blur room to fade instead of showing a hard edge at the viewport rim. Vary the four hex values and the durations to get anything from a calm night sky to a neon aurora.

Recipe 2 — grain without an image file

Grain is one CSS background: an inline SVG with a fractal-noise filter, URL-encoded so it can live inside a stylesheet. No request, no binary.

grain layer — one declaration
.grain {
  position: fixed; inset: 0; pointer-events: none; opacity: 0.22;
  mix-blend-mode: overlay;
  background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='140' height='140'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='.8' numOctaves='3'/%3E%3C/filter%3E%3Crect width='140' height='140' filter='url(%23n)'/%3E%3C/svg%3E");
}

baseFrequency sets the speck size (higher is finer), numOctaves adds detail, and %23 is a URL-encoded # — forget it and the browser silently drops the whole declaration. Toggle mix-blend-mode between overlay and soft-light to taste.

Recipe 3 — halftone dots and a mask

Two radial-gradient dot screens offset by half a tile give a print-like halftone. A radial mask-image fades it into the background, and scaling the layer makes the dots appear to breathe.

halftone layer
.dots {
  position: absolute; inset: 0;
  background:
    radial-gradient(circle, #e8edf6 1.1px, transparent 1.5px) 0 0 / 12px 12px,
    radial-gradient(circle, #7aa8ff 1.1px, transparent 1.5px) 6px 6px / 12px 12px;
  mask-image: radial-gradient(circle at 50% 50%, #000 0%, transparent 62%);
  animation: breathe 6s ease-in-out infinite alternate;
}
@keyframes breathe { from { transform: scale(0.9); } to { transform: scale(1.08); } }

The whole effect is two gradients and a mask — no <canvas>, no JavaScript. Change the tile size (/ 12px 12px) to switch between a subtle texture and a visible dot field.

Recipe 4 — canvas particles

When you need hundreds of moving objects, canvas is the right tool. The three details that make it look professional are the device-pixel-ratio scale, a cap on that scale, and a stop for readers who ask for reduced motion.

particles.js
<canvas id="sky" aria-hidden="true"></canvas>

<script>
  const canvas = document.querySelector('#sky');
  const ctx = canvas.getContext('2d');
  const dpr = Math.min(devicePixelRatio || 1, 2);
  const calm = matchMedia('(prefers-reduced-motion: reduce)').matches;

  const dots = Array.from({ length: 90 }, () => ({
    x: Math.random(), y: Math.random(),
    vx: (Math.random() - 0.5) * 0.0006,
    vy: (Math.random() - 0.5) * 0.0006
  }));

  function fit() {
    canvas.width = innerWidth * dpr;
    canvas.height = innerHeight * dpr;
    ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
  }

  function frame() {
    ctx.clearRect(0, 0, innerWidth, innerHeight);
    for (const d of dots) {
      d.x = (d.x + d.vx + 1) % 1;
      d.y = (d.y + d.vy + 1) % 1;
      ctx.beginPath();
      ctx.arc(d.x * innerWidth, d.y * innerHeight, 1.6, 0, Math.PI * 2);
      ctx.fillStyle = 'rgba(180, 214, 255, .8)';
      ctx.fill();
    }
    requestAnimationFrame(frame);
  }

  addEventListener('resize', fit);
  fit();
  if (!calm) requestAnimationFrame(frame);
</script>

Drawing in CSS pixels after setTransform(dpr, …) keeps coordinates readable while the canvas renders at full resolution. innerWidth/innerHeight are used instead of CSS sizes so the loop does not force layout every frame.

Recipe 5 — a poster from turbulence

SVG is the only place you get real procedural noise in a document. feTurbulence plus feDisplacementMap warps type and shapes; a duotone gradient behind them supplies the colour.

poster.svg — filters and duotone
<svg viewBox="0 0 600 800" role="img" aria-label="Generative poster">
  <defs>
    <filter id="warp">
      <feTurbulence type="fractalNoise" baseFrequency=".012 .02" numOctaves="4"/>
      <feDisplacementMap in="SourceGraphic" scale="36"/>
    </filter>
    <linearGradient id="duo" x1="0" y1="0" x2="1" y2="1">
      <stop offset="0" stop-color="#ff6b4a"/>
      <stop offset=".45" stop-color="#d92d5c"/>
      <stop offset="1" stop-color="#120d2b"/>
    </linearGradient>
  </defs>
  <rect width="600" height="800" fill="url(#duo)"/>
  <g filter="url(#warp)" fill="none" stroke="#f5f2ea" stroke-width="10">
    <circle cx="300" cy="360" r="170"/>
    <path d="M-40 620 Q 300 500 640 660"/>
  </g>
</svg>

Keep the filtered group small — filters rasterise every pixel they touch. For a page-wide poster, render it once into an SVG file and load it with <img>; the browser then caches the rasterised result.

Putting an effect behind your content

The recipes are complete pages so you can read them top to bottom. To use one in a real page, keep the effect layer absolutely positioned and decorative, and lift the text above it:

styles.css — hero with a code-drawn background
.hero { position: relative; isolation: isolate; overflow: hidden; padding: 96px 24px; }
.hero .mesh { position: absolute; inset: -20%; z-index: -1; filter: blur(48px); }
.hero .grain { position: absolute; inset: 0; z-index: -1; pointer-events: none; opacity: 0.2; }
.hero h1 { position: relative; margin: 0; font-size: clamp(2rem, 6vw, 4.5rem); }

isolation: isolate gives the blending its own stacking context, so mix-blend-mode combines with the effect layers and not with the rest of the site. pointer-events: none keeps the decoration from stealing clicks, and z-index: -1 keeps it behind the text without extra wrappers.

The six recipes Studio ships

RecipeTechniqueFile written
Animated gradient meshCSS radial fields + blur + inline-SVG graineffects/gradient-mesh.html
Canvas particle fieldCanvas 2D, DPR-aware, distance linkseffects/particles.html
Aurora ribbonsRotated gradient ribbons, blur, slow swayeffects/aurora.html
Animated halftoneTwo dot screens under a radial maskeffects/halftone.html
Glowing orbSpinning conic gradient + orbit ringseffects/orb.html
Generative posterSVG turbulence, displacement, duotoneeffects/poster.html

Each file is standalone: no build step, no CDN, no fonts to fetch. Open it directly at /preview/effects/poster.html, or copy parts of it into styles.css and your own markup.

Keep it fast and accessible

  • Animate compositor properties only. transform and opacity can run on the GPU; animating filter, background-position or gradient stops forces a repaint every frame.
  • Respect reduced motion. Wrap long animations in @media (prefers-reduced-motion: reduce) { .mesh span { animation: none; } }, as the shipped recipes do.
  • Decorate, then hide. Purely visual layers get aria-hidden="true"; a poster that carries meaning gets a real alt or nearby text.
  • Cap the pixel ratio. Rendering canvas at devicePixelRatio 3 triples the fill cost for no visible gain; Math.min(dpr, 2) is the usual compromise.
  • Pause off-screen work. For canvas loops, stop drawing when document.hidden is true or an IntersectionObserver says the canvas left the viewport.
  • Check contrast. Grain and glow sit under text; keep the text on a solid panel or verify it against the lightest point of the effect.

When it does not render

  • The grain disappears. A raw # inside a data URI ends the URL early. Encode it as %23.
  • The blur has a hard edge. The layer is exactly viewport-sized. Extend it with inset: -20% and clip the parent with overflow: hidden.
  • Canvas looks soft. Set canvas.width = innerWidth * dpr (not just a CSS width) and call ctx.setTransform(dpr, 0, 0, dpr, 0, 0).
  • An SVG filter is clipped. Filters paint outside the element's box; give the SVG a viewBox with margin, or set x/y/width/height on the filter element.
  • The effect is missing from dist/. The scaffold build only copies .html, .css, .js, .svg and .json, and skips dot-folders. Rename the asset or add its extension to the include regex in build.mjs.
  • The preview frozen on an old version. It reloads on save and after a build; if the file changed outside Studio, press the reload button.

Next: see how the same files flow through the terminal and the live preview in the website workflow, or how Studio is put together in Studio, files and the terminal.

Eltes Lab: training a model in the browser

Lab is the other half of the on-device story. CAI answers questions with rules; Lab is an actual neural network that starts from random weights and learns from text while you watch. It is a character-level language model — embeddings, one tanh hidden layer and a softmax over the vocabulary — trained by hand-written backpropagation and Adam. Everything runs in the tab: no server, no key, no download, and no network request at any point.

It is deliberately small. About 51,000 parameters on the default corpus, roughly nine seconds for a 1,500-step run on a laptop, and a vocabulary measured in dozens of characters. That is enough to watch a loss curve fall and to memorise a paragraph exactly. It is not enough to hold a conversation, and the page never pretends otherwise.

Train one

  1. Open Lab and pick a corpus: the Eltes handbook, an original story, or interface code. Each one changes the vocabulary and therefore the parameter count.
  2. Set the steps and seed. The seed initialises the weights and every batch, so the same settings reproduce the same curve exactly.
  3. Press Train model. The page trains in ~40 ms slices so it stays responsive, drawing the training and validation curves as they happen.
  4. Press Generate to sample. Temperature controls risk; top-k restricts the choice to the k most likely characters; length caps the run.
  5. Press Save model. The checkpoint goes to localStorage under eltes.lab.model.v1 and Eltes Lab-1 becomes usable in the workspace chat.

What is actually in the network

The default shape is a context of 14 characters, 24-wide embeddings and 128 tanh units. On the Eltes handbook (53 distinct characters) that is 51,245 weights:

TensorShapeWeightsRole
E53 × 241,272The embedding table. One learned vector per character, including the unknown token.
W1, b1336 × 12843,136The hidden layer over the concatenated 14-character window, followed by tanh.
W2, b2128 × 536,837The output layer: one logit per character, normalised by softmax.

Training uses a batch of 24 random windows, cross-entropy loss, dropout 0.1 on the hidden layer, weight decay 2e-5, gradient clipping at a global norm of 5, and Adam with bias correction at a learning rate of 0.003. The gradients are derived by hand — no autograd library, no WebAssembly, no GPU. A step takes about 6 ms in a browser and the page shows the measured figure for your machine.

The two curves, and why the dashed one turns up

The last 10% of the corpus is held out. Training loss is measured on text the model sees; validation loss is measured on text it never trains on. On a corpus this small the two diverge: training loss keeps falling towards zero while validation loss bottoms out after a few hundred steps and then climbs. That is overfitting, and it is the honest headline of a 51k-parameter model on 87 KB of text. The chart marks the best validation point and the readout names its step, so you can see exactly where the model stopped generalising and started memorising.

ReadoutWhat it means
Train lossCross-entropy on the training split. Falls with every useful update.
Val lossCross-entropy on held-out text, every 50 steps. The number that says whether it generalises.
Best valThe lowest validation loss and the step it happened. The ring on the chart.
Perplexityexp(loss) — how many characters the model is effectively choosing between.
TokensCharacter windows consumed, equal to steps × batch.
SpeedMeasured milliseconds per step in your tab, not a platform claim.

The memorisation test

Free generation from a model this small produces word-shaped fragments — that is what the budget buys. The fair test of whether training worked is to make the target trivially attainable: give the model one paragraph, repeated 24 times, and train until the loss approaches zero. Then prompt it with the first few words and compare the recital with the paragraph, character by character. The page reports the similarity, and a memorised paragraph scores above 90%.

This is memorisation, not understanding, and the page says so. It is also the clearest possible proof that the weights changed, the gradients flowed and the optimiser did its job — which is exactly what a demo that only ever prints confident text cannot show.

Use it in the chat

Once a checkpoint is saved, Eltes Lab-1 appears in the workspace model picker under On-device · no key. It continues the words you type and states its parameter count, corpus and validation loss above the quote. With no checkpoint it says it is not trained yet and links to Lab. Because a character model cannot follow instructions, the reply is a quoted continuation followed by a plain note that a character-level model wrote it.

the whole integration — app.js routes Lab-1 to EltesLab.chat
// assets/js/app.js, inside runLocalTurn()
if (model.id === 'eltes-lab-1' && window.EltesLab) {
  result = await streamLabTurn({ messages: history, signal: controller.signal, onToken });
} else {
  result = await CAI.stream({ modelId: model.id, messages: history, onToken });
}

// assets/js/lab.js
const lab = window.EltesLab;
const payload = lab.chat(messages);   // quotes a continuation, states its limits

Storage, exports and limits

  • One checkpoint per browser. The key is eltes.lab.model.v1; saving replaces the previous snapshot. The payload is a base64 dump of the Float32 weight arrays with the vocabulary and config — about 270 KB for the default shape.
  • Sample after loading, train again for a new run. A snapshot keeps the weights, not the corpus bytes, so a loaded model can generate but cannot continue the original run. The page labels the button accordingly.
  • Nothing is uploaded. There is no analytics on the page, no model download and no font or script CDN call.
  • Not a chatbot. No instruction following, no tools, no world knowledge and no coherent paragraphs. For an answer, use a hosted model; for an explanation of how a local model is trained, use Lab.

Desktop apps: Windows, macOS and Linux

Eltes ships a desktop harness under desktop/. It is a thin Electron shell around the same site and the same local API the browser build serves, so there is no second product to keep in sync: desktop/main.js starts tools/local-server.mjs on a free loopback port and loads app.html from that origin. A packaged app needs no Node install — it runs the server with its own binary via ELECTRON_RUN_AS_NODE.

one command per installer
# Windows: NSIS installer + portable .exe
npm run desktop:win

# macOS: .dmg + .zip (arm64 and x64)
npm run desktop:mac

# Linux: AppImage + deb
npm run desktop:linux

# one platform at a time, without installing anything
npm run desktop:stage   # assemble desktop/.build
npm run desktop:dir     # unpacked app, no installer

What is in the box

FilePlatformWhat it is
Eltes-Setup-<v>-x64.exeWindowsNSIS installer. Per-user by default, choose the folder, desktop and Start-menu shortcuts, runs after install.
Eltes-<v>-Portable-x64.exeWindowsOne executable, no install and no registry entries. Runs from a USB stick. An arm64 portable is built too.
Eltes-<v>-arm64.dmgmacOSDrag-to-Applications disk image for Apple silicon. An x64 build is produced the same way.
Eltes-<v>-linux-x86_64.AppImageLinuxSingle file: chmod +x, then run. No packages to satisfy.
eltes_<v>_amd64.debDebian/UbuntuInstalls a menu entry and the icon set; removes cleanly.

How the shell behaves

  • Hardened window. Context isolation on, node integration off, sandbox: true, no <webview>, and developer tools only with --dev. The page sees five calls through window.EltesDesktop and nothing else.
  • Navigation stays in the app. Only the app's own origin can navigate the window. Any other https: link opens in your real browser; file:, data: and javascript: are refused.
  • Loopback only. The bundled server binds 127.0.0.1 on a port chosen at launch (4173 upward), so a running dev server never collides and nothing is exposed to the network.
  • Nothing silently dies. If the server cannot start, the window shows a recovery screen with the error, the data folder, the log path and a Try again button.
  • Where your data lives. %APPDATA%\Eltes on Windows, ~/Library/Application Support/Eltes on macOS, ~/.config/Eltes on Linux. Studio writes to studio/ inside it (ELTES_STUDIO_DIR overrides), logs go to logs/desktop.log, and the window position is remembered in window-state.json.

Signing

The harness builds unsigned by default so anyone can reproduce the artifacts. Set the usual electron-builder variables to sign and notarise — no code changes:

signing is configuration, not code
# macOS Developer ID + notarisation
export CSC_LINK=/path/certificate.p12 CSC_KEY_PASSWORD=…
export APPLE_ID=… APPLE_APP_SPECIFIC_PASSWORD=… APPLE_TEAM_ID=…

# Windows Authenticode
export CSC_LINK=/path/certificate.pfx CSC_KEY_PASSWORD=…

Unsigned builds still work: Windows shows SmartScreen ("More info → Run anyway") and macOS needs a right-click → Open the first time. The macOS build is ad-hoc signed, so Apple silicon will launch it; distribution to other machines should be signed and notarised.

Continuous builds

.github/workflows/desktop.yml builds each platform on its own runner — windows-latest, macos-latest for Apple silicon, macos-15-intel for Intel and ubuntu-latest — then uploads the installers. Push a tag such as v3.18.0 (or run the workflow with a tag name) and the artifacts are attached to that GitHub release. Build Windows on Windows: cross-building the .exe from Linux skips the resource editor because rcedit needs Wine, and the icon and version metadata are then the Electron defaults.

Testing without a display

The harness core (desktop/shell.js, window-state.js, menu.js) is plain Node, so npm test covers flag parsing, deep links, the navigation policy, window clamping across removed monitors, atomic window state and the menu on each platform. Two tests go further: the real desktop/main.js runs against a mocked Electron module (boot, IPC, navigation guard, quit), and the packaged layout is assembled in a temp folder — site under resources/site, server started through the production code path, health checked, app.html and its assets fetched, Studio session opened, process stopped. Packaging config is validated against electron-builder's own JSON schema, and the shell is packed into an .asar to prove main.js survives the round-trip.

Settings

Open with ⌘, or the gear in the top bar. Settings are stored in eltes.settings.v1 and apply instantly.

PanelControls
GeneralDefault model, streaming, send key, tools by default, density, reduced motion, timestamps, metrics
API keysProvider keys, reveal/save/remove and provider key test through configured proxy, Vercel/local backend URL, custom model endpoint
ModelsStar favourites, choose the default model for new chats
AccountProfile, display name, sign out, delete profile, plan
DataChat count, storage meter, export all, import, delete all chats
AboutVersion, model count, shortcuts and links

Storage meter

The sidebar footer shows how much of the browser's local storage the app is using. The meter is deliberately conservative: browsers commonly allow about 5 MB per origin, and large attachments or very long threads consume it fastest.

Look and feel: the galaxy

The landing page, documentation, release notes and sign-in screen sit in front of a moving galaxy. It is two stacked <canvas> elements fixed behind the page, painted by assets/js/galaxy.js — no video, no image files, no libraries. The band, the spiral, the star cluster, the dust lanes, the star field and the meteors are all computed in the browser, so the scene is sharp at any size and costs one small script.

What is on screen

  • The backdrop — a tilted Milky Way with a hot cream core and a dark rift, a grand-design spiral in the upper right, a globular cluster upper left, an edge-on galaxy low on the left and four emission nebulae, painted once into a separate canvas.
  • The stars — thousands of baked specks plus six temperatures of animated stars at different depths; they drift, twinkle and lean a little toward the pointer.
  • Travel — fast scrolling stretches the nearest stars into short warp trails, so the page feels like it is moving through space.
  • Meteors — one every few seconds, drawn as a fading gradient with a bloom and a trail of sparks.
  • Grain and drift — a fine noise layer hides gradient banding, and the whole sky breathes through a 190-second drift.

Keeping it cheap

A background this rich is only acceptable if it stays out of the way. The renderer follows a few rules:

GuardWhat it does
Static layerThe nebula and dust are painted once into canvas[data-galaxy-backdrop]; only stars redraw.
Frame budget30 fps on desktop, 24 on touch devices — smooth motion, half the work.
Pixel ratio cap1.75 on desktop, 1.4 on phones. The painted backdrop uses at most 1.4 — soft art does not need more.
Hidden tabThe loop stops on visibilitychange and resumes when the page is visible again.
Reduced motionOne still frame is painted, then nothing more.
ResizeRepaints are debounced to 180 ms.
Motion budgetThe drift stops under prefers-reduced-motion, alongside the canvas loop.

Tuning it per page

The canvas reads its density from a data attribute. Lower numbers mean fewer, dimmer stars — useful on text-heavy pages:

markup — one attribute per page
<!-- index.html: the full scene -->
<canvas class="galaxy-canvas" data-galaxy="1.1"></canvas>

<!-- docs.html: quieter, so long text stays readable -->
<canvas class="galaxy-canvas" data-galaxy="0.72"></canvas>

The sky is decoration, not content: the container is aria-hidden="true", it never receives pointer events, and every piece of real information sits on an opaque-enough surface above it. The chat workspace and Studio deliberately do not use it — a moving background behind code and terminal output is noise.

Embers on the empty chat

The workspace stays quiet while you work, but the new-chat screen has a pulse. Move the pointer across it and a hearth of orange embers follows: a warm glow, eight sparks orbiting at different radii, wispy updraughts and embers rising from the composer. Speed is heat — move faster and it flares; stop and it settles. A click throws a burst.

It is one canvas (assets/js/chat-embers.js) under the thread, blending with screen so it only ever adds light. It starts when the empty screen is visible, fades out the moment a conversation begins, pauses in a hidden tab and paints a single still frame under reduced motion — the system setting or Settings → Conversation → Reduce motion, which the effect watches on <body>.

PointerWhat happens
StillFour embers a second drift up from the lower pane against a faint hearth glow.
MovingUp to nine a second near the pointer, an orbiting halo and wavy heat wisps; the glow brightens with speed.
ClickA burst of eight sparks and five embers, plus four extra wisps.
Leaving the paneThe glow fades back to the ambient burn instead of snapping off.

The chat is black, not blue

The workspace ships as Obsidian: #000 surfaces, silver accents and no blue anywhere in the shell, bubbles or composer. Graphite stays available under Settings → General for a softer grey. Both themes are tokens on body.app in assets/css/app.css, so the whole shell swaps from one block; there is no blue theme left to fall back to.

app.html — one attribute picks the theme
<body class="app" data-theme="black">  <!-- Obsidian -->
<body class="app" data-theme="grey">   <!-- Graphite -->

When it does not show

  • Nothing but a flat colour. JavaScript is off or the canvas failed to start. The container has a painted CSS fallback, so the page still looks intentional.
  • It is static. The system asks for reduced motion. That is deliberate; the still frame is the design, not a bug.
  • It is too busy. Lower data-galaxy on that page, or wrap the content in a panel with a solid background.
  • Battery drain on a laptop. The loop pauses in a hidden tab; if you want it gone entirely, remove galaxy.js and the two canvas elements — the CSS fallback remains.

Keyboard shortcuts

Command palette — chats, models, actions⌘K
Model picker⌘M
Toggle the sidebar⌘B
New chat⌘N
Settings⌘,
Focus the composer⌘/
Send / newline↵⇧↵
Close dialog or menuesc

On Windows and Linux, use Ctrl wherever ⌘ is shown. The interface relabels the modifier automatically.

Downloads

Everything Eltes ships is static. The Windows download is the same site wrapped in a portable launcher, so the only requirement is a way to serve files locally.

eltes-windows.zip
eltes-windows/
├── eltes.bat           # double-click to launch
├── Start-Eltes.ps1     # the actual server + browser open
├── README-WINDOWS.txt
├── index.html
├── app.html
├── auth.html
├── docs.html
└── assets/ …           # css, js, fonts, icons

What the launcher does

  1. Changes into its own folder, so it works from any drive or USB stick.
  2. Finds python, py -3 or node.
  3. Starts a static server bound to 127.0.0.1:4173.
  4. Waits for the port, then opens your default browser at the workspace.
  5. Leaves the server running until you close the console window.
It is a text file. Open eltes.bat in Notepad before running it. There is no installer, no elevation prompt and no outbound connection; the server listens on localhost only.

Optional Start-menu shortcut

cmd
:: create a shortcut in the Start menu
eltes.bat --shortcut

:: use a different port (default 4173)
eltes.bat --port 8080

macOS and Linux

Clone or unpack the same files and serve the root:

terminal
python3 -m http.server 4173
# then open http://127.0.0.1:4173/app.html

Install as an app

site.webmanifest declares standalone display, a dark theme colour and maskable icons at 192 and 512 px. Chrome, Edge and Safari can install the page as a window with the green-cloud icon; there is no Electron wrapper and no auto-updater.

Update by replacing files. Copy a new build over the old folder and keep localStorage. Chats, keys and your profile live in the browser, not in the download.

Pricing

The static Eltes workspace is free. Hosted replies require your own API key and may incur provider charges at its published rates. Eltes does not supply free hosted credits. CAI runs on-device without a key or per-token cost. There is no markup, subscription or card on file. A hosted tier with managed keys is in design; the pricing section shows pricing not announced because nothing is purchasable yet.

PlanStatusWhat you get
BYOKAvailable now · $0Every model, tool loop, local history, accounts, exports. You pay the provider.
HostedIn designManaged keys and sync without handling credentials. Pricing not announced.
Self-hostedAvailable now · $0Clone the repo and serve the static build wherever you like.
No bait pricing. Nothing on the site can take a payment. When the hosted plan is real, the pricing page will show the amount, the limits and the cancellation path before checkout.

Self-hosting

Publish with GitHub Pages

The repository’s .github/workflows/pages.yml workflow assembles and deploys only the static site files to GitHub Pages. Subsequent updates publish through the repository workflow. GitHub Pages serves HTML, CSS, JavaScript, images and the Windows download; it does not run the API backend, store secrets, proxy model requests, or grant provider free-tier access.

Without a configured proxy, users save keys in their browser and provider APIs receive requests directly from that browser, subject to provider CORS. Eltes also includes Node-based local API routes for development and separate Vercel functions for deployment.

Run the local site and API

With Node.js 18 or newer, run npm install and npm start, then open http://localhost:4173/app.html. This serves the static site and the three local API routes. The server binds to 0.0.0.0 for development-preview compatibility and has no user authentication; keep it on a trusted network. Use HOST=127.0.0.1 npm start to bind only to loopback. A Python static server serves only files, so it does not provide key testing or proxy routes.

Deploy the optional proxy to Vercel

  1. Import the repository into Vercel with the project root as the root directory. The functions are in api/; vercel.json sets a 60-second function limit.
  2. Set the project environment variable ELTES_ALLOWED_ORIGINS to exact comma-separated browser origins, without paths or trailing slashes. Example: https://gefrus112.github.io,http://localhost:4173.
  3. Deploy, then in Eltes Settings → API keys → Optional Vercel/local backend, enter the Vercel deployment origin and use Save and test.

The available routes are /api/health, /api/key-check, streaming/non-streaming /api/chat, and /api/search. The backend only contacts fixed provider hosts and allowlisted provider/model pairs; it never accepts an arbitrary upstream URL. CORS origin checks are not authentication; these routes are public to non-browser clients unless you add access controls. Do not offer an unprotected shared proxy to untrusted users without adding authentication and rate limiting. The key transits the backend for each call; the function code does not persist or log it, but the platform and provider process the request. Use only a deployment you trust and set provider spending limits. Static hosting and Vercel backend deployment are separate; this documentation does not mean a deployment has already occurred.

repository layout
eltes/
  index.html            # marketing home
  app.html              # the workspace
  auth.html             # local accounts
  docs.html             # this page
  assets/
    css/                # theme, landing, app, docs, auth
    js/                 # models, brands, icons, app engine
    fonts/              # Inter + JetBrains Mono, self-hosted
    img/                # favicon and social card
  site.webmanifest
  LICENSE · NOTICE.md

Deployment checklist

  • Serve over HTTPS in production. The clipboard API and some provider endpoints require a secure context.
  • Keep every asset path relative so the app works from a subdirectory.
  • Do not add analytics that read localStorage; it holds keys.
  • If you add a content security policy, allow connect-src for each provider you use.
Sharing with a team? Host the static build, then give each person their own keys. There is no shared key store by design.

Privacy

Eltes includes no analytics, telemetry, or third-party font requests. In direct mode, a model request goes from your browser to the selected provider. If you configure the optional backend, key checks and model requests transit that backend; the function code does not persist or log provider keys, but the backend platform processes request metadata and the key while handling the call.

What Eltes stores

eltes.chats.v1 — conversations and per-message metadatalocal
eltes.settings.v1 — provider keys, backend URL, model default, preferenceslocal
eltes.account.v1 — optional profilelocal
eltes.cloud.v1 — Supabase session, if cloud accounts are onlocal
eltes.waitlist.v1 — pricing waitlist entries, if you joinlocal
No cookies, no fingerprinting, no session replaynone

What providers see

Your prompt, the conversation history sent with it, and whatever tool definitions or results are included in that request. That is exactly what any chat client must send to get an answer. Eltes adds no account identifier, because it does not have one to add.

Deleting data

Use Settings → Data to delete all chats, or Account → Delete profile to remove the profile. Keys can be removed individually. Clearing site data for the origin removes everything at once.

FAQ

Why is there no demo mode?

A simulated answer is indistinguishable from a wrong one. Eltes would rather show a connect card or a typed error than pretend a model replied. If you want to try the interface without a paid provider, run Ollama locally and use a custom endpoint.

Do I need an account?

No. Accounts are optional profiles. The workspace, keys, history and exports work without one. Create a profile only if you want a name and avatar attached.

Is it safe to paste an API key into a web page?

Only on a page you trust and control. Eltes is static and self-hostable, so you can audit every line and serve it yourself. Keys sit in localStorage, which any script on the origin can read — do not load untrusted third-party scripts alongside it.

Why does a provider fail with a network error?

Usually CORS. The provider must allow the browser origin in direct mode. See Providers & CORS, or configure the optional Vercel/local backend in Settings → API keys.

Which models can use tools?

Models with tool support in the catalogue: Claude 5.5 and 4.5, GPT‑6 Sol/Luna, GPT‑4.1, Gemini 2.5, GLM-4.x, DeepSeek V4.1/V4, Grok, Qwen3, Mistral Large, Llama 3.3/4 and most OpenRouter routes. The Eltes CAI models run their own local intents and Codestral is sent the plain conversation.

Can I use it offline?

Yes, partly. Eltes CAI-1 answers chat, Eltes questions, arithmetic, units and dates with no network at all. Hosted models need a connection, unless you point a custom endpoint at a model running on your machine. The Windows launcher serves the whole site locally.

Can I sign up with OpenAI, Anthropic or DeepSeek?

Yes. The account page offers all three. On a static copy, connecting means validating and storing a real provider key in this browser. On a hosted deployment with ELTES_CONFIG.authEndpoint, the same buttons start a real OAuth redirect. GitHub sign-up has been removed.

What is the Windows .bat file?

eltes.bat is a readable launcher included in eltes-windows.zip. It finds Python or Node, starts a static server on 127.0.0.1:4173 and opens the workspace. It does not install a service, touch the registry or need admin rights.

What happens to my chats if I clear site data?

They are gone. localStorage is the only copy. Export a JSON backup from Settings → Data first if the thread matters.

Ready to connect a real model?

Bring one key. Eltes does the rest — streaming, tools, metrics and history that stays here.

Open the workspace