# Luma

URL: https://sola.ysz.life/projects/luma

> Bring your own keys, talk to any model

- Type: Personal project
- Period: Mar 2026 - Apr 2026
- Live: https://luma.ysz.life
- Source: https://github.com/lyfe691/luma
- Stack: Next.js App Router, TypeScript, Vercel AI SDK, Supabase, PostgreSQL, Tailwind CSS, Motion, Shiki, Base UI, shadcn/ui, Three.js

Luma is a web chat app for many model providers that runs on the user's own API keys, built with Next.js, the Vercel AI SDK and Supabase. Conversations are stored as a tree of messages, and keys are encrypted with AES-256-GCM and decrypted on the server only for the request that uses them.

## A multi-provider AI chat that runs on your own API keys

[Luma](https://luma.ysz.life) is a chat app for people who already pay for API access with one or more model providers. You add your keys once, pick a model for each chat, and every request goes to that provider on your key.

![Luma's start screen in the light theme: grey streaks fly outward behind the word Luma, and a question about how a B-tree splits a full node is typed into the input below it](https://sola.ysz.life/projects/luma/start.webp)
*The start screen.*
[Video](https://sola.ysz.life/projects/luma/start.mp4)

### Picking a model

The model button in the input opens a picker with every model Luma offers, grouped by provider. A rail of provider logos jumps between the groups, the search box filters as you type, and small icons mark what each model handles, such as reasoning, images as input, tool use and PDFs. A star adds a model to a favourites list on the rail.

Each chat keeps its own model, so one conversation can run on Claude and the next on GPT-5 Mini. Models whose provider has no key yet carry a lock, and picking one puts a note above the input that names the missing key, with a button that opens settings.

![The model picker opening above the input, with provider logos down the left and models grouped under OpenAI and xAI; typing claude narrows the list to three Anthropic models, the first one gets a star, and the favourites entry on the rail then lists only that model](https://sola.ysz.life/projects/luma/model-picker.webp)
*Searching the picker and starring a model.*
[Video](https://sola.ysz.life/projects/luma/model-picker.mp4)

### Asking

The input takes text, images and files such as PDFs, CSVs and Markdown, pasted, dropped onto it or picked from the plus menu. The same menu sets a mode for the next message:

- **Create image** asks the model to answer with a picture.
- **Web search** has it look things up and cite its sources.
- **Study and learn** asks for structured teaching with worked examples.

![The plus menu opening under the input with Add photos & files, Create image, Study and learn and Web search; Create image is chosen, an Image chip appears in the input, and a prompt about a sunset over a field of flowers is typed](https://sola.ysz.life/projects/luma/modes.webp)
*The plus menu, and the image mode.*
[Video](https://sola.ysz.life/projects/luma/modes.mp4)

A generated image appears in the chat with the name of the image model under it. Which image model draws it is a setting of its own, separate from the chat model.

![A chat where the request Generate an image of a cute sunset is answered with an illustration of a smiling sun setting over a field of flowers, captioned Generated with Grok Imagine](https://sola.ysz.life/projects/luma/image-generation.webp)
*A generated image, with the image model's name under it.*

### Answers

Replies stream in as Markdown. Code blocks are highlighted and carry copy and download buttons, and maths and diagrams render in place. When a model reasons before it answers, the reasoning folds into a "Thought for N seconds" row above the reply, and Claude models get extended thinking without anything to switch on. Answers from a web search carry their sources as small chips next to the sentences that use them.

![A chat with Claude Opus 4.6: two folded reasoning rows, then a highlighted Rust program with line numbers and download and copy buttons, and a short explanation under it](https://sola.ysz.life/projects/luma/code-block.webp)
*Claude's reasoning, folded above a code answer.*

### Editing, retrying and finding chats

Nothing in a conversation is overwritten. Editing a message you sent adds a new version next to the old one, with a fresh reply under it, and arrows under the message flip between the versions. Retrying an answer does the same on the reply side, and the retry menu can send the question to another model, so two models' answers to the same message sit one arrow apart.

New chats are titled by a small, cheap model on one of your keys. The sidebar lists them with the pinned ones first, and each can be pinned, renamed or deleted from there. Settings can export all your chats as a download, or delete them. The common actions have shortcuts, with Ctrl on Windows and ⌘ on a Mac:

| Shortcut             | Does                               |
| -------------------- | ---------------------------------- |
| Ctrl K               | Search every chat, grouped by date |
| Ctrl Shift O         | Start a new chat                   |
| Ctrl M               | Open the model picker              |
| Ctrl U               | Attach a file                      |
| Ctrl Shift S         | Show or hide the sidebar           |
| Shift Esc            | Jump to the message input          |
| Ctrl Shift Backspace | Delete the open chat               |

### Your API keys

Settings has a row for every provider, each with a link to the page where you get a key. Luma tries a new key against the provider before it saves it, so a key the provider rejects is refused on the spot. Keys are encrypted before they're stored, and the browser only ever learns which providers have one.

![The settings dialog over the start screen, open on API Keys: a note that keys are encrypted at rest and never leave the server, and rows for OpenAI, Anthropic, Google, xAI and Mistral, each with a Get API key link and an Add key button](https://sola.ysz.life/projects/luma/api-keys.webp)
*Adding keys in settings.*

### Light and dark

Luma comes in a light and a dark theme with an olive green accent, and it follows the system unless you pick one. The streaks behind the start screen follow the theme, grey on light and white on dark. On a phone the sidebar becomes a drawer, and the model picker and settings open as sheets from the bottom of the screen.

![The start screen switching from the light theme to the dark theme and back from the theme control at the bottom of the sidebar; the streaks turn from grey to white](https://sola.ysz.life/projects/luma/theme.webp)
*The light and dark themes.*
[Video](https://sola.ysz.life/projects/luma/theme.mp4)

### How it's built

Luma is a Next.js 16 app on React 19. The Vercel AI SDK streams the replies and reaches each provider through that provider's own package, Supabase holds the accounts, the chats and the uploaded files, and the streaks on the start screen are a Three.js shader.

#### Conversations are trees

Every message is a row with a `parent_id`, and a chat stores one more pointer, `active_leaf_id`, to the last message of the thread on screen. The thread is derived: start at the active leaf, follow parent pointers up to the root, reverse. An edit or a retry adds a sibling under the same parent, and the walk marks each message that has siblings of its own role with its place among them, which is all the arrows under a message read.

`lib/chat-tree.ts`

```ts
export function buildTimelineMessages(
  graph: ChatGraph,
  activeLeafId: string
): UIMessage[] {
  const path = getPathToLeaf(graph, activeLeafId)
  if (path.length === 0) return []
  return path.map((node) => {
    const siblings = (graph.childrenByParentId.get(node.parentId) ?? []).filter(
      (candidate) => candidate.role === node.role
    )
    const branchCount = siblings.length
    const branchIndex = Math.max(
      0,
      siblings.findIndex((candidate) => candidate.id === node.id)
    )
    return {
      id: node.id,
      role: node.role,
      parts: node.parts,
      metadata: {
        createdAt: node.createdAt,
        ...(branchCount > 1
          ? ({
              branchCount,
              branchIndex,
              isActiveBranch: true,
            } satisfies ChatBranchMetadata)
          : {}),
      },
    } satisfies UIMessage
  })
}
```

Switching versions is a server call that takes the next sibling, follows the newest child down to a leaf and saves that leaf as the active one. The cost of the design is that opening a chat reads every message in it, including the branches you aren't looking at. In exchange they arrive in one query on an index over chat and creation time, and the tree code is plain TypeScript that the browser and the server both import.

#### From a model id to a provider client

Model ids carry their provider, as in `anthropic:claude-opus-4-6`. The chat route checks the id against the catalogue and falls back to the default when it isn't there, so the browser can't ask for a model Luma doesn't list. The prefix selects the user's key, which is decrypted for that one request, and the provider's AI SDK package, which is loaded with a dynamic `import()` so a request only pulls in the SDK it needs. Providers with an OpenAI-compatible API reuse the OpenAI package with their own base URL, so adding one is a catalogue entry.

#### Keys at rest

A key is encrypted with AES-256-GCM under a fresh random IV and stored as IV, ciphertext and authentication tag. Because GCM authenticates the ciphertext, a value changed in the database fails to decrypt instead of turning into a different key. This protects the keys in a leaked database dump. The server has to call providers with the plaintext key, so it can decrypt every key it stores, and so can anyone who holds both the database and the master key.

#### What goes back to the model

A stored reply keeps everything the screen shows: reasoning, text, tool calls and their results. The history sent with the next request is rebuilt from the tree on the server, and three things are stripped from it first:

| Part of the reply | Sent back as                     | Why                                                                                     |
| ----------------- | -------------------------------- | --------------------------------------------------------------------------------------- |
| A generated image | The text `[image was generated]` | The base64 would otherwise ride along with every later request                          |
| Reasoning         | Nothing, it stays on screen only | OpenAI's Responses API can reject reasoning items replayed from history                 |
| Provider metadata | Nothing                          | It points at one provider's earlier responses, so a thread can switch providers mid-way |

Attachments stay in a private bucket, and each request signs fresh links to them that expire after ten minutes.

![Luma's start screen in the dark theme: the sidebar on the left and the input in the middle over white streaks on a near-black field](https://sola.ysz.life/projects/luma/start-dark.webp)
*The start screen in the dark theme.*

![The start screen on a phone in the light theme: the word Luma over grey streaks, and the input at the bottom](https://sola.ysz.life/projects/luma/start-mobile.webp)
*Start, on a phone.*

![The model picker as a bottom sheet on a phone: the provider rail on the left and the OpenAI and xAI models with capability icons and stars](https://sola.ysz.life/projects/luma/model-picker-mobile.webp)
*The picker, as a sheet.*
