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:

  1. No key is ever written to the vault. It's plain text, headed for git.
  2. Desktop: OS keychain via Tauri. Web: in memory for the session, asked again next time, and say so plainly rather than pretending.
  3. The host makes the request, never a plugin. A plugin gets the response.
  4. 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

  1. Generate PKCE. A random verifier, SHA-256 via WebCrypto, base64url — about 10 lines, no library.
  2. Open the authorize URL. https://openrouter.ai/auth?callback_url=<app>&code_challenge=<c>&code_challenge_method=S256 The web build's callback is a route that reads ?code=. Tauri can either register a slate:// deep link or omit callback_url entirely — 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.
  3. Exchange it. POST https://openrouter.ai/api/v1/auth/keys with { code, code_verifier, code_challenge_method: 'S256' } returns { key, user_id }. The key belongs to the user's account.
  4. Store, fetch, and finish the job. Key to the OS keychain (desktop) or session memory (web); GET /api/v1/models populates the model menu; the default is set to openrouter/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/models is 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 how ai.title ends up on a $0.02 model while ai.rewrite stays 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.