Documentation
AI & providers
Ollama, OpenRouter's keyless connect, custom endpoints, and prompt notes.
The goal: AI available everywhere in the app — writing in the editor, acting on the vault — the way omarchy's Default Agent menu makes an agent available everywhere in the desktop. This document covers how to get there, and it opens with the one constraint that shapes everything else.
1. The subscription question, answered honestly
You cannot call a ChatGPT Plus, Grok/X Premium, or Claude Pro subscription from a third-party app. Those plans are sold for the vendor's own clients. There is no OAuth flow that grants a subscription's inference entitlement to someone else's editor. The only ways to reach one are session-cookie scraping or unofficial endpoints — terms-of-service violations that break without warning and can get your users' accounts suspended. Don't build on that.
But the outcome you want is reachable, and the screenshot you sent is the proof: omarchy's Default Agent menu doesn't call any API. It picks which already-installed, already-authenticated CLI to launch — Claude, Codex, Copilot, Gemini, opencode, crush. The subscription works because the vendor's own tool is the one using it.
That is the loophole, and it's a legitimate one:
| Path | Who authenticates | Subscription works? |
|---|---|---|
| Agent CLI subprocess (omarchy's model) | the vendor's own CLI, already logged in on the machine | ✅ yes — this is the answer |
| BYOK API key | the user pastes a key; you call the vendor's HTTP API | ❌ no — metered, pay-per-token |
| Local model (Ollama, LM Studio) | nobody; it's on localhost | ✅ free, private, no account |
| Hosted "Slate AI" | your server holds the keys, the user pays you | ✅ but it's a business, not a feature |
| Scraping a consumer session | — | 🚫 ToS violation, breaks constantly |
As of today, the CLIs worth detecting that accept a subscription login are Claude Code (Pro/Max), OpenAI's Codex CLI (ChatGPT Plus/Pro), GitHub Copilot CLI (Copilot), and Gemini CLI (Google account). Verify each before shipping a claim about it — vendor plans change faster than documentation.
Consequence for the roadmap: the subscription path requires launching a subprocess, which the browser cannot do. It is a Tauri/M5 feature. The web build gets BYOK and local models. That's not a compromise to apologize for — it's a genuine reason the desktop build exists, and it matches the audience.
2. The seam
Slate already has the pattern that solves this: StorageAdapter. One interface,
several implementations, nothing above it knows which one is live — it's why the
Tauri build is additive rather than a rewrite. Do the same thing for inference.
// src/ai/types.ts
export interface AgentProvider {
readonly id: string // 'claude-cli' | 'anthropic' | 'ollama'
readonly name: string // 'Claude Code' — what the picker shows
readonly kind: 'cli' | 'http' | 'proxy'
/** Present and runnable right now? Drives the picker's live list. */
available(): Promise<boolean>
/** Streaming is the whole UX. Everything is a token iterator. */
run(req: AgentRequest, signal: AbortSignal): AsyncIterable<AgentChunk>
}
export interface AgentRequest {
system: string
prompt: string
/** Assembled by the caller and shown to the user before it is sent. */
context: ContextBlock[]
/** Providers may ignore this; the picker surfaces what each supports. */
model?: string
}
export type AgentChunk =
| { type: 'text'; value: string }
| { type: 'error'; message: string }
| { type: 'done'; tokens?: number }
Three implementations cover every path in the table above:
| Provider | Notes |
|---|---|
CliProvider |
Tauri shell, one binary per agent, stdout streamed line by line. Subscription-friendly. Detection is which claude, which codex, which gemini… |
HttpProvider |
One adapter for anything OpenAI-compatible (OpenAI, xAI, Groq, OpenRouter, Ollama, LM Studio) plus one for Anthropic's shape. Two adapters, a dozen vendors. |
ProxyProvider |
Identical interface pointed at your own endpoint, if a hosted plan ever ships. Nothing else changes. |
Rule: every provider streams, and every call is cancellable. A blocking AI
call in an editor is the same failure as a plugin blocking the editor, and
PRD §5.7 already made that a hard guarantee. Esc kills the stream.
3. What the user sees
The design constraint is the one the whole app is built on: no settings screen, and every action is a command. AI must not become a chat sidebar.
The picker — omarchy's menu, in the palette we already have
Add 'agents' to PaletteMode and bind ⌘⌥A. It lists every provider that
reports available(), with the detected ones first, exactly like the Default
Agent menu: a name, a dim badge (cli · local · key), and the model.
Picking one sets the default. Zero new UI surface — the palette does it all.
Providers that need a key show — add key instead of a model, and picking one
opens the existing Prompt to paste it.
The actions are commands, not a panel
Everything AI does is a registered command, which means it is instantly
searchable in ⌘P, bindable, discoverable in ⌘/, and available to plugins:
| Command | Binding | What it does |
|---|---|---|
ai.console |
⌘⌥K |
The console. Every action below, in one overlay — see AI-CONSOLE.md. |
ai.continue |
⌘⌥↵ |
Streams a continuation at the caret. Type a sentence, press it, keep writing. The one action with no UI, because a panel to finish a sentence is slower than the sentence. |
ai.rewrite |
⌘⌥R |
The console, opened on Rewrite: the passage stays on the page and the result arrives beside it. |
ai.ask |
— | Asks about the current note. The answer lands in the note as a > blockquote, editable and deletable like any text. |
ai.askVault |
— | The same question across the notes it matches, with every claim cited as a [[wikilink]]. |
ai.summarize |
— | Summary at the top of the note, or into a linked note. |
ai.title / ai.tags |
— | Suggests a title or tags from the content — the two chores nobody enjoys. title renames the file, because here the filename is the title. |
ai.link |
— | Proposes [[wikilinks]] to existing notes it thinks are related. The one AI feature that makes a vault better rather than a document. |
ai.tasks |
— | Pulls the commitments buried in prose out as - [ ] checkboxes. |
ai.query |
— | Plain English in, a real tag:… path:… -excluded search string out. |
ai.chat |
⌘⌥C |
Opens a conversation as a pane (⌃⌥ tiling already exists) backed by a real note in chats/. Nothing lives in a database. |
Only four of those carry a binding. The rest are rows in the console and lines
in ⌘P — which is the point of collapsing the dialogs: a new AI action costs a
row, not a keystroke out of a keyboard that has run out of them.
Output is always text in a file. That is the product's promise and it's also
what makes the feature reversible: ⌘Z undoes AI the same way it undoes typing.
Prompts are notes
prompts/*.md works exactly like templates/*.md does today — the same
loader, the same {{title}}/{{date}}/{{selection}} placeholders. A user's
prompt library is versioned with their vault, shareable as files, and
extensible without a settings screen or a plugin. This is the highest
value-per-byte piece in the entire design; build it early.
It turned out to be worth more than that. Once a prompt note can also declare
scope: and landing: — the console's own two variables — it is not a canned
instruction any more, it is a whole AI feature in a file. Translation, tone,
outlining, digests: each is a .md file somebody can write, share, and edit,
and none of them is code. See AI-CONSOLE.md §2.4.
Context must be visible
Before anything is sent, the user can see what's going: selection → current
note → linked notes → search hits, as a one-line summary with a token estimate
in the statusline (⇡ 1.2k · claude-cli). While a request is in flight, that
segment shows a live indicator; the moment a request leaves the machine, the
user knows. Local providers show ⌂ instead, because they didn't.
4. Credentials
PLATFORM.md already set these rules and AI is the reason they exist:
- No key is ever written to the vault. It's plain text, headed for git.
- Desktop: OS keychain via Tauri. Web: in memory for the session, asked again next time, and say so plainly rather than pretending.
- The host makes the request, never a plugin. A plugin gets the response.
- Nothing requires an account. With no provider configured, Slate is the editor it is today, and every AI command is simply absent from the palette.
If a hosted "Slate AI" plan ever ships, it is ProxyProvider plus billing, and
it must remain an option among providers — never the path of least
resistance, and never a gate on the editor.
5. Build order
Stages 0-2 shipped on 2026-08-26 (src/ai/, 24 tests). What landed: the
provider seam, OpenAI-compatible streaming with cancellation, Ollama detection,
OpenRouter with the PKCE connect flow and the public model catalog, both palette
menus with a pinned shelf, ⌘⌥. cycling, ai.continue, ai.ask, and the
statusline agent segment, ai.rewrite with an accept/revert region, and
prompts/*.md, and ai.chat — conversations as notes under chats/, opened
in a tiled pane.
Stage 4 shipped on 2026-08-27, and it arrived by a route this table did not
predict. Building ai.link meant naming its context and its destination, and
once those two had names it was obvious that ai.rewrite was the same shape
with different values — so stage 4 became the console (AI-CONSOLE.md) plus
nine actions on top of it, rather than four separate commands. The visible
context budget this section asked for is the console's left pane, and it fills
in before the request rather than reporting on one already sent.
Stage 3 (agent CLIs) still needs Tauri and is now the only stage left before 5.
| Stage | Contents | Cost |
|---|---|---|
| 0 — the seam ✅ | AgentProvider, streaming plumbing, cancellation, the 'agents' palette mode |
~150 lines |
| 1 — one provider, one command ✅ | HttpProvider (OpenAI-compatible → point it at Ollama and test with no key at all) + ai.continue streaming into CodeMirror |
~200 lines |
| 2 — the writing set ✅ | ai.rewrite with a diff overlay, ai.ask → blockquote, prompts/*.md |
~250 lines |
| 4 — vault-aware ✅ | The console, ai.link, ai.tags, ai.title, ai.askVault, ai.query, scope assembly + the visible budget, scope:/landing: in prompt notes |
~700 lines, and it deleted the rewrite panel |
| 3 — CLI providers (Tauri) | Detection, subprocess streaming, the subscription path | ~200 lines, desktop only |
| 5 — optional | Dictation (Whisper via Tauri, or Web Speech on the web) — omarchy's Voxtype slot | plugin |
Stage 1 against Ollama is the right first milestone: no key, no account, no billing, nothing to leak, and it proves the streaming path end to end. Every later provider is then a file, not a feature.
Where this sits against FEATURES.md
Tier 2 lists AI as a plugin, and that stays true for vendors and extras. But the seam — provider interface, streaming, cancellation, the picker — is small, touches the editor, and every plugin would otherwise reinvent it. Ship the seam in core behind a lazily-imported chunk so it costs zero bytes until the first AI command runs, and let plugins add providers and prompts on top. That respects the byte rule without pretending an editor-level feature can live entirely outside the editor.
6. The three services, evaluated
Checked against current docs, not memory. Short version: take OpenRouter, skip Cloudflare until you sell something, and treat the Vercel AI SDK as an implementation detail you can adopt later without changing anything.
None of the three solves the subscription question in §1 — only the agent-CLI path does. What OpenRouter does solve is the key-pasting problem, which is most of what makes BYOK feel bad.
OpenRouter — yes, and it's the one to build first
It is an OpenAI-compatible endpoint. That means it is not new architecture at
all; it is HttpProvider with a different base URL:
// The entire integration.
const res = await fetch('https://openrouter.ai/api/v1/chat/completions', {
method: 'POST',
headers: { Authorization: `Bearer ${key}`, 'Content-Type': 'application/json' },
body: JSON.stringify({ model: 'anthropic/claude-sonnet-4.5', messages, stream: true }),
signal,
})
The part that matters for your question is OAuth PKCE. OpenRouter lets an app send the user to
https://openrouter.ai/auth?callback_url=<app>&code_challenge=<c>&code_challenge_method=S256
and exchange the returned code for a key scoped to their account. There is
also a headless mode (omit callback_url) for apps that can't host a redirect,
which is the Tauri case. The user clicks Connect, authorizes, and never
sees an API key. Their credits, model access, and org settings come from their
own OpenRouter account.
That is as close to "sign in instead of managing keys" as anything legitimate
gets, and it lands one ⌘⌥A entry away from everything else:
| Wins | one connection → 300+ models; no key pasting; user pays the vendor, not you; automatic fallbacks; works identically on web and desktop; ~60 lines |
| Costs | prepaid credits, not a flat subscription; a third party sits between the user's notes and the model — pass provider: { zdr: true } for zero-data-retention routing and say so in the UI; does not unlock a Claude Pro or ChatGPT Plus plan |
Verdict: build it as the second provider, right after Ollama. Ollama proves the streaming path with nothing to leak; OpenRouter makes it useful for everyone else on the same code.
Cloudflare AI Gateway — right tool, wrong stage
The gateway URL is account-scoped —
gateway.ai.cloudflare.com/v1/{account_id}/{gateway_id}/compat/chat/completions
with a cf-aig-authorization token — and its BYOK-stored-keys and Unified
Billing features exist so that the gateway's owner holds the credentials and
the billing relationship.
For Slate today that is backwards. Either every user creates a Cloudflare
account and their own gateway (nobody will), or you own the gateway and
every user's notes flow through your infrastructure — which is the hosted
service PLATFORM.md says must never sit in the default path.
But note the endpoint shape: /compat/chat/completions is OpenAI-compatible.
So if a hosted "Slate AI" plan ever ships, the gateway is ProxyProvider,
and adopting it costs a base URL and a header. Its caching, spend caps,
per-user analytics, and provider fallbacks are exactly what you'd want the day
you're paying the inference bill.
Verdict: not for v1. Zero rework cost to adopt later — which is the whole point of the seam.
Vercel AI SDK — optional, and it is not the architecture
It can run client-side; loadApiKey deliberately throws in a browser unless
you pass apiKey explicitly, and streamText takes an abortSignal with an
onAbort callback, which maps onto the cancellation rule in §2 exactly. So it
would work.
The question is whether it earns its weight. Against it: nearly every example
in its docs is a server route, because that's its intended posture; it's a
dependency tree (ai plus an @ai-sdk/* package per vendor) inside an app
whose core rule is under ~10KB; and it would sit inside HttpProvider
rather than replacing anything. Streaming OpenAI-compatible SSE with fetch is
about 60 lines and no dependency.
For it: one interface across providers, and — the real reason to want it — tool calling and structured output. When AI stops writing prose and starts acting on the vault (create this note, add these links, retitle these five), typed tools with schema validation are worth a great deal more than 60 lines of SSE parsing.
Verdict: skip for Stages 1-3, revisit at Stage 4 when ai.link and
vault-acting commands arrive — and lazy-load it, so anyone who never runs an AI
command never downloads it.
The stack this argues for
| Audience | Provider | Auth |
|---|---|---|
| Privacy-first / offline / the Arch crowd | Ollama | none |
| Everyone else | OpenRouter | Connect (OAuth PKCE), no key |
| Power users with their own accounts | direct Anthropic/OpenAI/xAI | pasted key |
| Subscription holders (desktop) | agent CLI subprocess | the vendor's own login |
| A hosted plan, if it ever ships | Cloudflare AI Gateway | your billing |
Five rows, one AgentProvider interface, two HTTP adapters. That is the whole
surface.
7. Onboarding, and the two menus
There is no AI settings screen and no onboarding wizard. Setup happens at the moment of first use, and it ends by completing the action the user pressed a key for — never by dropping them in a configuration screen and making them find their way back.
Two menus, not one
The distinction matters, and conflating them is how these UIs get confusing:
| Menu | Key | Question it answers |
|---|---|---|
| Agent | ⌘⌥A |
Who serves my requests? Ollama, OpenRouter, a CLI, a raw key. Omarchy's Default Agent menu. |
| Model | ⌘⌥M |
Which model, within that agent? The 417-row one. |
| (neither) | ⌘⌥. |
Cycle to the next pinned model. No menu at all — the statusline just changes. |
Both are Palette modes. Same centered overlay, same monospace, same fuzzy
matcher, same hairline border, same ↵/esc. Two entries in PaletteMode
and two row renderers — no new component, no new visual language.
The agent menu
┌────────────────────────────────────────────────┐
│ agent │
├────────────────────────────────────────────────┤
│ ▸ Ollama local · detected │
│ OpenRouter connect · 417 │
│ Claude Code cli · subscription │
│ Anthropic key │
│ OpenAI-compatible… key · base url │
└────────────────────────────────────────────────┘
Detected things come first and say so. Nothing here is a form: the right-hand
badge tells you what picking the row will cost you — detected means it works
now, connect opens a browser once, key opens the existing Prompt to paste
one, cli · subscription means the plan you already pay for.
Connecting OpenRouter: four steps, one of them in a browser
- Generate PKCE. A random verifier,
SHA-256via WebCrypto, base64url — about 10 lines, no library. - Open the authorize URL.
https://openrouter.ai/auth?callback_url=<app>&code_challenge=<c>&code_challenge_method=S256The web build's callback is a route that reads?code=. Tauri can either register aslate://deep link or omitcallback_urlentirely — OpenRouter's headless mode then shows a code to paste, which needs no URL scheme at all. Ship headless first; deep-link later if it's worth the polish. - Exchange it.
POST https://openrouter.ai/api/v1/auth/keyswith{ code, code_verifier, code_challenge_method: 'S256' }returns{ key, user_id }. The key belongs to the user's account. - Store, fetch, and finish the job. Key to the OS keychain (desktop) or
session memory (web);
GET /api/v1/modelspopulates the model menu; the default is set toopenrouter/auto; and then the original command runs.
The user pressed ⌘⌥↵ to continue a sentence. Four steps later the sentence
continues. They never saw an API key, a settings screen, or a model list they
didn't ask for.
The model menu
417 rows is not a menu, it's a phone book. So the menu has a shelf and a catalog: the handful you actually use, then everything else behind typing.
┌───────────────────────────────────────────────────────────┐
│ model ▏son │
├───────────────────────────────────────────────────────────┤
│ ★ anthropic/claude-opus-5-fast $10/$50 1000k │
│ ★ openrouter/auto routed 2000k │
│ ★ x-ai/grok-4.20 $1.25/$2.50 2000k │
│ │
│ 414 more │
│ google/gemini-3.1-pro-preview $2/$12 1049k │
│ qwen/qwen3.8-max $2/$6 1000k │
│ mistralai/mistral-nemo $0.02/$0.03 131k │
├───────────────────────────────────────────────────────────┤
│ ↵ use ⌘↵ pin ⌫ unpin ⌥↵ use for this command │
└───────────────────────────────────────────────────────────┘
- The vendor prefix is dim, the model name is bright — the same trick the
compacted folder rows use (
journal/2026/08). It makes 417 monospace rows scannable without a single icon or logo. - Price and context are real data, straight from
/api/v1/models, right aligned and tabular. A 1000× price spread is a fact the user should see at the moment of choosing, not discover on an invoice. ⌘↵pins — that is "add model".⌫unpins. The shelf is the answer to "cycle between my models"; the catalog is the answer to "add a new one".- The catalog needs no key.
/api/v1/modelsis public, so the menu is fully browsable before connecting — you can see what you'd be getting first. ⌥↵sets the model for the invoking command only, which is howai.titleends up on a $0.02 model whileai.rewritestays on a flagship.
Where the shelf lives
In the config note (PLATFORM.md §3): a models: list in slate.md
frontmatter. Pinning writes a line of YAML into a note the user owns. It is
versioned with the vault, editable as text, portable between machines, and
costs no settings screen. Per-command overrides live in the prompt note's own
frontmatter, so prompts/title.md can carry model: mistralai/mistral-nemo.
Cycling
⌘⌥. steps to the next pinned model and flashes it in the statusline —
⇡ grok-4.20 — exactly the way theme.cycle works today. No overlay, no
confirmation. For anything more deliberate, ⌘⌥M is one key away.
What this costs
| Piece | Cost |
|---|---|
| Two palette modes + row renderers | ~150 lines |
| PKCE + code exchange + keychain write | ~70 lines |
| Model catalog fetch, cache, and shelf | ~40 lines |
model.cycle command + statusline segment |
~20 lines |
Under 300 lines, and every one of them is on top of a palette, a prompt, a command registry, and a statusline that already exist.
8. The traps
- Don't build a chat sidebar. It's the default design and it's wrong here: the output escapes the file, which breaks the one promise the app makes.
- Don't grow a dialog per action. Eight AI commands had produced three different surfaces before anyone noticed. Name what varies — context in, result out — and the ninth action is a row instead of a fourth dialog.
- Don't auto-send on keystroke. Ghost-text completion as you type feels
magical for a week and then costs money on every idle cursor. Make invocation
explicit;
⌘⌥↵is cheap to press. - Don't let context grow silently. "Include linked notes" quietly becomes 40k tokens on someone's real vault. Show the number, cap it, let them see it.
- Don't gate the editor on a network call. Offline is a normal state.
- Don't ship a "clean up my note" button that overwrites. Every mutating AI command must land as a diff the user accepts, or as new text they can delete.