pi-lemonade-link

Unified pi extension for a self-hosted Lemonade server: dynamic chat-model discovery, multimodal agent tools (transcription, image gen/edit/upscale, TTS, audio, 3D mesh), a live /lemonade-setup management TUI, and a lemonade-only below-editor status bar (

Packages

Package details

extension

Install pi-lemonade-link from npm and Pi will load the resources declared by the package manifest.

$ pi install npm:pi-lemonade-link
Package
pi-lemonade-link
Version
1.0.1
Published
Sep 18, 2026
Downloads
179/mo · 179/wk
Author
prawnbear
License
MIT
Types
extension
Size
398.3 KB
Dependencies
0 dependencies · 3 peers
Pi manifest JSON
{
  "extensions": [
    "./index.ts"
  ]
}

Security note

Pi packages can execute code and influence agent behavior. Review the source before installing third-party packages.

README

pi-lemonade-link

A pi extension that connects pi to a self-hosted Lemonade server — a local LLM/audio/image server with GPU acceleration — and keeps that connection in sync with whatever the server is actually doing.

No hardcoded endpoints, no manual model lists. One extension, five capabilities:

  1. Live model discovery — every chat model your lemonade box advertises shows up in /model automatically, annotated with whether it's loaded right now.
  2. Nine agent tools — transcription, image generation/editing/upscaling, text-to-speech, music/SFX, 3D meshes, and text classification, all callable by the LLM, all model-aware.
  3. A setup menu/lemonade-setup opens a navigable TUI for status, discovery, configuration, model management (live-progress pulls, Hugging Face installs), and live log streaming.
  4. A lemonade status bar — while a lemonade model is active, an extra below-editor row appears (your footer is never touched): instance name, tok/s and prefix-cache hit % derived locally from the session's own messages, plus polled busy/queue, CPU/GPU/NPU and VRAM from the active instance. Switching to a non-lemonade model removes the row.
  5. Harness self-awareness — every chat, including virgin ones, is told where its lemonade machinery lives, so the agent can operate it directly.

Why this exists

Lemonade is a server, and its contents change constantly: you load models, unload them, pull new ones. pi, by contrast, learns about models from a static file. The job of this extension is to erase that gap: every time pi starts, it asks lemonade what it currently has, and registers exactly that.

Design philosophy — why this is built pi-native

This extension is not just a feature bridge; every structural choice reflects how pi itself works:

  • Dynamic truth over static files. pi learns providers from a static file; this extension re-asks the server at every startup and re-registers, so /model always mirrors reality — down to the loaded / on-demand annotations, which come from live health state, never guesses.
  • Native tools, not protocol shims. Every capability is a plain pi.registerTool() function with a promptSnippet — pi's own advertisement mechanism, flattened by the harness into the provider's dialect. MCP appears only where it's genuinely MCP: lemonade's own gateway ships as an optional, disabled stdio entry for other MCP-consuming setups. Trusted in-process code is never wrapped in JSON-RPC — that would add lifecycle overhead and couple failure domains for zero benefit.
  • Harness-level self-awareness. The model has no inherent knowledge of the machinery it runs on — the harness must tell it, in the one place it reads every turn. The promptGuidelines lines inject the machinery's location and the configured instance list into every chat's system prompt, so "what's loaded on your server?" resolves deterministically in a virgin chat instead of by lucky path-hunting.
  • Advertisement that reflects reality. Tool snippets use a [lemonade] prefix (a corpus-bucket convention, so tools group visibly by origin in the prompt), and classify_text's description is generated per session from the live catalog — the advertisement enumerates the classifiers that actually exist on the box, and refreshes itself on every startup.
  • Everything configurable, nothing hardcoded. Every endpoint is a path key in lemonade.json. Moving the server, changing ports, or tracking upstream API changes is config, not code.
  • Live state where it matters. Pulls stream real progress from server-owned download jobs with Esc-to-cancel; the status panel streams the full state of the box — every metric from five endpoints, grouped by source, re-fetched live while open, cyclable across instances with n/p; the status bar surfaces per-turn tok/s, cache hits, box busyness and VRAM while you chat, appearing only when a lemonade model is active; logs stream over websocket. Destructive actions gate behind confirm dialogs and a typed phrase.
  • Failures speak. Missing models produce errors naming what to pull — the agent relays and recovers. Never a silent failure.
  • One entry point. A single command — /lemonade-setup — every capability navigable from one TUI menu; no command sprawl.
  • Ground truth snapshotted. docs/ holds the official API spec so this extension can be audited against it offline, and the behavior reference records what was empirically confirmed on which server build.

Quick start

  1. Make sure your lemonade server is running and reachable.
  2. Configure an instance — either way works:
    • Zero-touch: just start pi. If ~/.pi/agent/lemonade.json doesn't exist, the extension creates a minimal, commented blank one (zero servers) and shows an info notice pointing you at /lemonade-setup, which opens a first-run wizard: Discover servers (UDP beacon + HTTP fallback) or Add instance manually. Once the first instance exists, everything registers and the normal menu takes over — no restart needed.
    • By hand: copy the fully-commented lemonade.example.json to ~/.pi/agent/lemonade.json and point the first servers entry's baseUrl at your server. Nothing is hardcoded, so the file is the source of truth — a config that exists but is unparseable or invalid still fails loudly (red error, nothing registered).
  3. Start pi. Every chat-capable model on the server appears in /model as lemonade-<instance-name>/<model-id> (e.g. lemonade-main/Qwen3-8B-GGUF), labeled loaded or on-demand.
  4. Pick one and chat. Models marked on-demand are auto-loaded by lemonade when first requested — if the target slot is occupied by a pinned model, lemonade replies with a slots_pinned_error you'll see as an error message.
  5. Say "transcribe /path/to/file.mp3" in any session — the agent calls transcribe_audio and Whisper returns the text.
  6. Type /lemonade-setup to manage everything interactively.

How the pieces work

Model discovery

At every startup (and after /reload, and after any change made in the setup menu), the extension queries two endpoints:

  • GET /v1/models — the catalog. Each entry carries rich metadata: labels (chat, reasoning, vision, transcription, image…), recipe, max_context_window, downloaded, size.
  • GET /v1/health — live state: which models are actually loaded, per-type slot limits, the realtime websocket port.

It then registers one pi provider per configured instance, uniformly named lemonade-<instance-name>, with one pi model per catalog entry, mapped as follows:

Lemonade says pi registers
labels contains chat (or recipe: llamacpp) included (by default; see Chat-only filter)
labels contains reasoning reasoning: true
labels contains vision image input enabled
max_context_window context window
model appears in /health name suffix (lemonade-<instance>, loaded)
not in /health name suffix (lemonade-<instance>, on-demand)

If the server is unreachable at startup, the provider registers nothing — an unreachable model is not listable, so /model gets no lemonade entries until the box is reachable. (Consequence, accepted: sessions pinned to a lemonade- model hard-fail model resolution while offline — "Model not found" — and recover on the next startup or /lemonade-setup → Refresh once the box is back.)

Chat-only filter — lemonade also hosts transcription (Whisper), image generation (Flux), and other non-chat models. pi can only drive chat-completions models, so those are filtered out by default. Toggle via Model management → Toggle chat-only filter if you want them listed anyway.

Compat flags — the provider is registered with lemonade-appropriate settings (max_tokens instead of max_completion_tokens, system instead of developer role, no reasoning_effort), so requests work out of the box.

Agent tools

The extension registers nine tools the LLM can call on your behalf. Two facts apply to all of them:

Instance selection. Every tool takes an optional server argument naming a configured instance (omit for the default). "default" is kept as a reserved alias; the default's actual name works too. Unknown names produce an error listing available instances. See Multiple lemonade instances.

Model selection precedence. Tools that need a model resolve it in this order: (1) an explicit model argument in the tool call, (2) the extension's configured default (defaultImageModel, defaultUpscaleModel, defaultTranscriptionModel, defaultClassifierModel in lemonade.json), (3) auto-pick — the first catalog model carrying the right label (image, edit, tts, ...) or recipe (onnxruntime for classifiers). Set a default in /lemonade-setup or config if you want a specific model to win.

How results come back. Text results (transcripts) are returned inline into the conversation. Binary outputs (images, audio, meshes) are written to diskoutputDir (default: the agent's working directory) as lemonade-<kind>-<timestamp>.<ext> — and the tool returns the saved path. The model relays the path to you. To view a generated image in the TUI, ask the agent to read the PNG afterwards: pi renders images inline in supported terminals (Kitty, iTerm2, Ghostty, WezTerm, Warp), and reading it also lets the model itself see and verify its own output. The TUI cannot render audio or .glb meshes at all — those live on disk only.

Per tool:

Tool What it does Model resolution Output Caveats
transcribe_audio Speech → text defaultTranscriptionModel (Whisper-Large-v3), else first catalog model labeled transcription/whisper transcript returned inline (not saved) the transcription API accepts raw wav only — every other format (mp3, mp4, m4a, webm, flac, ...) is converted to 16 kHz mono wav via ffmpeg on pi's machine first. Unloaded Whisper auto-loads into the separate transcription slot
generate_image Text → PNG arg → defaultImageModel → first catalog model labeled image PNG on disk + path First call may include model load (observed ~12 s). Turbo models want steps: 4, cfg_scale: 1; the tool schema documents this for the model
edit_image Image + prompt → edited PNG arg → defaultImageModel → first model labeled edit (then image) PNG on disk + path multipart upload of the source PNG; optional mask_path (white = edit, black = preserve). Edit-capable models on current build: the Flux family
vary_image Image → variation PNG arg → defaultImageModel → first edit/image model PNG on disk + path no prompt parameter — variation is derived from the input image alone
upscale_image Image → 4x PNG arg → defaultUpscaleModel (RealESRGAN-x4plus) PNG on disk + path needs an upscale model pulled on the server; -anime variant exists for art. Sends the image as base64 JSON, not multipart
text_to_speech Text → spoken audio arg → first model labeled tts (OpenMOSS-TTS on current builds) mp3/wav on disk + path if the TTS model isn't pulled, the request can trigger a slow auto-pull that outlasts the request — pull it via the setup menu first. voice/speed/response_format supported
generate_audio Prompt → music / SFX arg → first model labeled music/sfx/audio wav on disk + path ACE-Step for music (optional lyrics with [verse]/[chorus] tags = sung vocals; omit = instrumental), ThinkSound for SFX. Not downloaded by default — pull first
classify_text Text → ranked label confidences arg → defaultClassifierModel → first catalog model with the onnxruntime recipe inline text readout encoder classifiers (phishing, PII, prompt-injection...) — millisecond inference in its own classification slot; scores returned highest-first, so the top line is the verdict. Pull classifiers via the setup menu or HF install
generate_3d_model Image → textured .glb arg → first model labeled 3d (TRELLIS-3D) .glb on disk + path minutes per mesh; resolution 512/1024/1536; needs TRELLIS pulled. TUI cannot render it — open the file in a 3D viewer

How classify_text handles multiple classifiers and modalities. Each classifier's label universe (phishing/benign, PII/no-PII, 1–5 stars…) is baked into the model at training time — the tool is a pipe, not a policy. Three layers make multi-classifier use workable: (1) the tool's prompt advertisement dynamically enumerates the classifiers actually on the server each session; (2) per-call selection via the model argument (or defaultClassifierModel config); (3) runtime self-description — the response's labels reveal the chosen model's universe, so a mismatched question ("is this a hot dog?" sent to a phishing model) returns an obviously wrong label set and the agent retries with the right model. Modality boundaries are architectural: /v1/classify serves text encoders only — image classification routes to vision-capable chat models (read the image), and audio classifies only after transcribe_audio. Sequence-classification models return the flat ranked scores; token-classification models (e.g. PII span detectors) return additional structure, which the tool passes through verbatim.

Missing-model behavior is uniform: if no suitable model exists on the server, the tool returns a clear error naming what to pull — the agent relays it and suggests pulling via /lemonade-setup. Never a silent failure.

Lemonade also exposes realtime transcription over a websocket (ws://<host>:<ws-port>/realtime?model=…; the port comes from /health). This extension does not wire that up — it's for live-mic streaming, not file transcription.

Agent self-awareness

Every chat's system prompt — including virgin chats with no lemonade context — carries three guideline lines injected by this extension (via pi's promptGuidelines mechanism, attached to transcribe_audio):

  • where the machinery lives: ~/.pi/agent/lemonade.json (base URL, endpoints, defaults) and this README + docs/ (full reference)
  • what to do with it: query the server's HTTP API directly via bash + curl for state questions (loaded models, health, host resources), and point the user at /lemonade-setup for interactive management
  • which instances exist: the configured fleet, by name and URL, and that tools take an optional server argument (this line is generated per session)

So asking the agent "what models are loaded on your server?" in a fresh chat resolves deterministically: read the config, curl /v1/health, summarize — no hunting for paths, no lucky guesses.

Lemonade status bar (below-editor widget)

While a lemonade model is active, the extension adds one extra row below the editor (pi's setWidget mechanism, belowEditor placement). It is a separate slot from the footer: the built-in footer and any other extension's custom footer are never touched, and the row disappears the moment a non-lemonade model is selected. session_start and model_select events drive the lifecycle; the instance name comes from the active model's provider field (lemonade-<instance> — pi keeps provider and id separate; the lemonade-<instance>/<model> form is picker-display only), so switching between instances swaps the row onto the new box automatically. If the model isn't resolvable at session start, the first turn re-checks.

🍋 stackshack · 14.9 tok/s · cache-hit 99% · idle · CPU 2% · GPU 2% · NPU 0% · VRAM 38.8G

The layout is fixed — every segment is present from the first render with placeholders until its value lands, and segments never appear or vanish afterwards. Narrow terminals truncate the tail (VRAM first).

Segment Source Cost
🍋 <instance> the active model's provider field none
<n> tok/s the session's own message_startmessage_end timing over usage.output — always your stream, immune to the server's last-client stats none (event-driven, updates as each message completes)
cache-hit <pct>% the session's own usage: pi-ai maps llama.cpp prefix-cache hits onto usage.cacheRead (and usage.input is the uncached remainder), so the hit rate is cacheRead / (cacheRead + input) per request — never another client's numbers none (per message)
idle / busy / busy ·Nq / offline polled /v1/health (is_busy/is_streaming) + /metrics queue depth (requests_processing + requests_deferred); offline in warning color when the box can't be reached polled every barPollMs
CPU <n>% · GPU <n>% · NPU <n>% · VRAM <n>G polled /v1/system-stats (cpu_percent, gpu_percent, npu_percent, vram_gb). A box whose system-stats reports npu_percent: null drops the NPU segment permanently (decided once on first poll — no flicker); an unreachable box keeps the last-known gauge values under the offline marker polled every barPollMs

Notes:

  • tok/s is the last completed message's rate — it includes TTFT, so it reads slightly conservative vs. llama.cpp's own tps. It does not animate mid-generation (the working spinner covers that).
  • barPollMs: 0 drops the polled segments; the row degrades to instance + tok/s + cache-hit %.
  • statusBar: false disables the row entirely.
  • A poll in flight when you switch models is discarded — the new instance's row never shows the old box's numbers.

/lemonade-setup menu

A navigable TUI (↑↓ / enter / esc). On a fresh install (zero instances configured), /lemonade-setup instead opens a first-run wizardDiscover servers (UDP beacon + HTTP fallback) or Add instance manually — and the normal menu takes over once the first instance exists.

Menu What you can do
Server status The complete state of the box, grouped by the endpoint each metric came from — every server metric flat on one live panel that re-fetches every statusPollMs (default 2 s) while open, with each snapshot timestamped in the header; a transient fetch failure keeps the last good snapshot. Scrollable (wrap-aware ↑↓/j/k/pgup/pgdn/home/end scrolling + mouse wheel, q/esc/enter return; position indicator in the footer). With multiple instances configured, n/p cycles between boxes without leaving the view — the title names the instance shown, and the menu's active instance follows it on exit. Sections: /v1/health (loaded models with busy/streaming/backend-health/pid/slot-pool, per-type slot limits, pinned counts, telemetry state), /v1/system-stats (CPU/RAM used-total-%, GPU, VRAM, NPU), /v1/stats (last-request tok/s, TTFT, in/out tokens, prefix-cache hit rate, lifetime token/request counters, routing decisions), /v1/system-info (CPU cores/threads, OS, GPU/NPU inventory incl. NPU power mode and TOPS, model-storage drive usage), and /metrics (llama.cpp backend: request queue depth, busy slots per decode, peak sequence length). Each section degrades to an "(unreachable)" line independently — one dead endpoint never blanks the panel
Server settings Discover servers (UDP beacon + HTTP fallback), edit base URL, edit API key, test connection
Model management List catalog (including not-yet-downloaded registry entries), load / unload, pull (download) with live progress + esc-to-cancel, install from Hugging Face (search → pick variant → install as user.*), delete, change context size (unload → reload with ctx_size, saved), toggle chat-only filter, refresh provider
Transcription settings Default Whisper model, endpoint path, smoke-test a file
Live server logs WebSocket stream of the server's log: full snapshot backlog (up to 5000 entries) then live entries as they happen; Esc to close
Spec & version check The three drift tiers, interactive: docs-vs-upstream status (TUI-only), live per-instance server versions beside the verified build and the latest upstream release, re-check now, one-keystroke docs re-sync (re-downloads upstream docs into the extension's docs/ folder — the code is not updated), specCheck / serverVersionCheck / upstreamVersionCheck toggles

Notes on the destructive and slow paths:

  • Delete requires two gates: a confirm dialog, then typing the exact phrase i want to delete <model-id> — three attempts allowed, each prompt shows chances remaining, and Esc aborts immediately.
  • Change context size prefills the model's current context (from live health), accepts 32k, 1m, or raw token counts, and warns that the model is unloaded and reloaded (the new ctx_size is saved for future loads).
  • Pull uses lemonade's server-owned download jobs: a live progress view (percent, bytes, per-file status, updated every second) with Esc to cancel (via /v1/downloads/control). Blocking pull is kept only as a fallback for older servers without job support.
  • Install from Hugging Face searches the registry through the server (/v1/registry/search), lists quantization variants with sizes (/v1/pull/variants), and installs the chosen one as a user.* model — including vision/mmproj detection — with the same live progress view.

Every change is saved to the config file and re-registers the provider immediately — no restart or /reload needed.

Server discovery — lemonade broadcasts a JSON beacon ({"service":"lemonade","hostname":…,"url":…}) roughly once per second on UDP port 13305. The setup menu can listen for it, then falls back to probing common ports on localhost and the currently configured host.

WSL2 note: UDP LAN broadcasts do not propagate into WSL2's NAT'd virtual network, so the beacon may never arrive if pi runs inside WSL2. The HTTP fallback still works, and manual URL entry always works.

Configuration

Everything lives in ~/.pi/agent/lemonade.json — and it is created for you on first run: if the file is missing, the extension writes a minimal, commented blank config (zero servers) instead of blocking, shows an info notice pointing at /lemonade-setup, and opens a first-run wizard there (discovery or manual entry). A config that exists but is unparseable or invalid still fails loudly at startup (red error in the chat window, nothing registered), because no endpoint is hardcoded and a broken file must not target a server that isn't yours.

The tracked lemonade.example.json in this folder is the canonical reference: fully commented, documenting every key and its default, and doubly useful as a template:

cp ~/.pi/agent/extensions/pi-lemonade-link/lemonade.example.json ~/.pi/agent/lemonade.json

Edit the first servers entry's baseUrl, trim the rest to taste, restart pi. // comments are allowed in the config file (the loader strips them); note that saving via /lemonade-setup rewrites the file and removes comments, so keep annotations in your copy of the example. Changes made in /lemonade-setup apply immediately — no restart or /reload needed.

A minimal working config is one entry:

{ "servers": [{ "name": "main", "baseUrl": "http://your-lemonade-server:13305" }] }

The full shape (values shown are the built-in defaults):

{
  "servers": [
    { "name": "main", "baseUrl": "http://localhost:13305",
      "description": "Primary lemonade server." }
  ],
  "apiKey": "lemonade",
  "chatPath": "/api/v1",
  "modelsPath": "/v1/models",
  "healthPath": "/v1/health",
  "loadPath": "/v1/load",
  "unloadPath": "/v1/unload",
  "pullPath": "/v1/pull",
  "deletePath": "/v1/delete",
  "downloadsPath": "/v1/downloads",
  "downloadsControlPath": "/v1/downloads/control",
  "registrySearchPath": "/v1/registry/search",
  "pullVariantsPath": "/v1/pull/variants",
  "transcriptionPath": "/v1/audio/transcriptions",
  "imageGenerationPath": "/v1/images/generations",
  "imageEditPath": "/v1/images/edits",
  "imageVariationPath": "/v1/images/variations",
  "imageUpscalePath": "/v1/images/upscale",
  "speechPath": "/v1/audio/speech",
  "audioGenerationPath": "/v1/audio/generations",
  "mesh3dPath": "/v1/3d/generations",
  "classifyPath": "/v1/classify",
  "beaconPort": 13305,
  "chatOnly": true,
  "defaultTranscriptionModel": "Whisper-Large-v3",
  "defaultImageModel": "",
  "defaultUpscaleModel": "RealESRGAN-x4plus",
  "defaultClassifierModel": "",
  "outputDir": "",
  "discoveryTimeoutMs": 5000,
  "beaconTimeoutMs": 3000,
  "loadTimeoutMs": 300000,
  "pullTimeoutMs": 1800000,
  "transcriptionTimeoutMs": 300000,
  "generationTimeoutMs": 600000,
  "statusPollMs": 2000,
  "statusBar": true,
  "barPollMs": 5000,
  "specCheck": true,
  "specCheckCooldownMs": 86400000,
  "specCheckTimeoutMs": 5000,
  "serverVersionCheck": true,
  "upstreamVersionCheck": true
}
Key Meaning
servers Required — the only instance store: [{ "name", "baseUrl", "apiKey"?, "description"? }]. May be empty on a fresh install (the first /lemonade-setup run offers a wizard); the FIRST entry is the default instance (targeted when tools' server argument is omitted); reordering changes the default. Each registers as a lemonade-<name> provider.
apiKey Shared fallback Authorization: Bearer … for instances without their own. A dummy value works for auth-less servers (pi requires non-empty auth to list models).
*Path API endpoint paths, in case a future lemonade changes them.
chatOnly Filter the provider down to chat-capable models.
defaultTranscriptionModel / defaultImageModel / defaultUpscaleModel / defaultClassifierModel Fixed model ids for the tools. Empty = auto-pick from the live catalog (by label, or by onnxruntime recipe for classifiers).
outputDir Where generated files (images, audio, meshes) are saved. Empty = agent's working directory.
*TimeoutMs Per-operation timeouts. pullTimeoutMs only caps the blocking pull fallback — normal pulls use download jobs with live progress.
statusPollMs How often the setup-menu status panel re-fetches while open (live refresh). 0 = static snapshot.
statusBar Show the below-editor lemonade status bar while a lemonade model is active (see Lemonade status bar).
barPollMs Server-poll cadence for the status bar's busy/queue + CPU/GPU/NPU/VRAM segments. 0 = local metrics only (instance, tok/s, cache-hit %).
specCheck / specCheckCooldownMs / specCheckTimeoutMs Docs-freshness check: the docs/ snapshot is compared against upstream lemonade-sdk/lemonade by git blob SHA — TUI-only status, no startup warning (extension ↔ server agreement is what matters; see Keeping the specs honest); the verdict is durable for specCheckCooldownMs (default 24 h, also the upstream-release probe's throttle).
serverVersionCheck Tier 1, startup warning: /v1/health self-reports the server version; any instance reporting a version other than the build this extension was verified against (v11.7.0 — a constant in the extension code, not a setting) warns with direction: server newer → check for an extension update; server older → update the server.
upstreamVersionCheck Tier 2, info notice: servers older than the latest published lemonade release (probed via GitHub, throttled) get a one-line "a server update is available" notice. Default on; separately toggleable.

Offline & unreachable networks

When pi starts on a network with no route back to the lemonade LAN (different WAN, VPN down, box asleep):

  • Startup never blocks longer than discoveryTimeoutMs (~5 s by default — lower it in config if you work offline often).
  • Only the lemonade provider is affected — every other provider in /model (built-ins, other extensions) is untouched.
  • No lemonade listings when unreachable. If an instance can't be reached at startup or refresh, it contributes nothing to /model — an unreachable model is not listable. This is fully automated in both directions: entries appear exactly when the box is reachable and disappear when it isn't (until the next startup or Refresh re-checks). The accepted consequence: sessions or scripts pinned to lemonade-<name>/<model> hard-fail model resolution while offline (Error: Model "lemonade/<id>" not found), and resume working once the box is back and a Refresh has re-registered it. Non-reachable chat requests are the honest outcome; phantom listings are never shown.
  • Tools fail with actionable text, not cryptic errors: every tool detects the unreachable server and returns the same message — where it tried to connect and what to do about it (reconnect, or point the extension at a reachable instance via /lemonade-setup). No misleading "pull a model first" advice when the real problem is the network, and no bare fetch failed.
  • The setup menu diagnoses it: Server status shows an explicit Unreachable: <health URL> panel, and Test connection reports the failure.
  • The agent can self-diagnose: the self-awareness guidelines point it at the config, so asking the agent "are you connected to lemonade?" resolves via a direct probe rather than guessing.

Levels of availability checking (each with one job, each defined once):

Level When What it does Where the logic lives
Registration probe every startup / refresh decides what goes into the provider: the live catalog, or nothing at all when unreachable — no phantom listings registerInstanceProvider
Tool fetch every tool call converts network rejections into the actionable unreachable message; user-cancels pass through toolFetch() — used by all nine tools
Auto-pick model resolution distinguishes unreachable (null) from no-match (undefined) so advice fits the situation pickModelByLabels / pickModelByRecipe
Instance resolution every tool call with a server arg resolves the named box's config view or returns an error listing known instances instanceView()

Tools stay deliberately stateless — reachability is a per-call fact (networks change mid-session), so no connection state is cached between calls. The only repetition is the three-line binding guard at the top of each tool, which is what makes the rest of each tool body instance- and failure-aware for free.

Multiple lemonade instances

The extension supports simultaneous multi-instance use. Each configured lemonade box becomes its own pi provider, every tool can target any instance, and the setup menu operates per-instance.

Configuration — there is no separate default instance: servers[] is the only instance store, and the first entry IS the default. Every entry has a name (required) and an optional description (free-form context for your own reminder's sake). The shared top-level apiKey applies to any entry that doesn't define its own:

{
  "servers": [
    { "name": "main", "baseUrl": "http://192.168.1.10:13305",
      "description": "Primary lemonade server." },
    { "name": "second-server", "baseUrl": "http://192.168.1.20:13305",
      "description": "The other box, mostly for experiments." }
  ]
}

Names identify instances everywhere, without prejudice: provider ids, model addresses, and picker brackets are uniformly lemonade-<name> for EVERY instance — the default reads [lemonade-main] exactly like an extra reads [lemonade-second-server]. Descriptions are free-form context shown in the instance lists; edit both via Manage instances → Edit instance. Renaming any instance changes its provider id (and re-registers it), so pinned sessions and --model lemonade-<old-name>/... references must follow the rename — the cost of a perfectly uniform namespace.

How instances are exposed:

  • Providers: every instance registers as lemonade-<name> — the default included, no special case. Picker brackets, model addresses, and provider column all read lemonade-<name> uniformly. Each is independently annotated loaded / on-demand from that box's own health state. Unreachable boxes register nothing — there are no offline placeholders for any instance.
  • Tools: every tool takes an optional server argument naming an instance. Omitted → default. Unknown names return an error listing the available instances — never a wrong-box dial. Model auto-pick, status, and everything else resolve against the selected instance's catalog.
  • Self-awareness: the prompt guideline enumerates the configured instances by name and URL every session, so virgin chats know the fleet without hunting.
  • Setup menu: a Switch instance item (appears when extras exist) picks which box Status, Model management, and Live logs act on — and the status panel itself cycles instances in-place with n/p, syncing the menu's active instance on exit; Server settings → Manage instances lists (with live reachability), adds, and removes instances; discovered servers can be registered as default or as a named instance.

The one-place rule: instance semantics are defined exactly once — instanceView() returns a config view (same settings, swapped baseUrl/apiKey) that flows through every existing helper unchanged, so tools, discovery, TUI actions, and error handling all inherit instance behavior without per-tool logic.

Reference documentation (API ground truth)

This folder's docs/ directory contains the complete official Lemonade endpoint specification, snapshotted from the upstream repo (lemonade-sdk/lemonade, docs/api/) on 2026-09-12 — the same markdown the docs site renders. It is the source of ground truth for every endpoint, parameter, and response shape this extension uses:

File Contents
docs/README.md Spec index and design philosophy
docs/openai.md OpenAI-compatible surface: chat/completions, embeddings, audio (transcription, speech, generations), images (generation/edit/variation/upscale), realtime, /v1/models + labels taxonomy
docs/lemonade.md Lemonade-specific surface: load/unload/pull/delete, download jobs + control, registry search + pull variants, health/stats/system-stats/system-info, classify, audio/3D generation, log streaming, job engine, install/uninstall
docs/mcp.md The MCP gateway (lemonade as an MCP server)
docs/ollama.md, docs/anthropic.md, docs/llamacpp.md Other compatibility surfaces (rerank, slots, etc.)

When lemonade updates, re-sync with:

git clone --depth 1 --filter=blob:none --sparse https://github.com/lemonade-sdk/lemonade /tmp/lemonade \
  && cd /tmp/lemonade && git sparse-checkout set docs/api \
  && cp docs/api/*.md ~/.pi/agent/extensions/pi-lemonade-link/docs/

Keeping the specs honest

Drift is watched in tiers, ordered by what actually matters at runtime:

  1. Extension ↔ server sync (startup warning). Every registration and refresh compares each instance's live /v1/health version against the build this extension was verified against (v11.7.0 — a constant in the extension code, not a user setting: it is a property of the code, updated with each extension release). A mismatch is the one signal that means endpoints may have changed for this pairing, so it warns — directionally: server newer → "check for an extension update"; server older → "update the server, or expect some endpoints to be missing".
  2. Server ↔ latest published release (info notice, one sentence, on by default, separately toggleable). The latest published lemonade release is probed via GitHub (throttled, durable, silent on network failure); any server older than it gets a single info line — "instance 'x' runs lemonade v11.7.0; the latest published release is v11.9.0 — a server update is available." Much lower tier: an older server isn't broken, it's just behind. upstreamVersionCheck: false disables.
  3. Docs drift — TUI-only status, deliberately no startup warning. The docs/ snapshot is the reference for the version the extension targets; as long as extension and server agree on version, whether upstream HEAD has moved on changes nothing at runtime — so it's shown only in /lemonade-setup → Spec & version check (per-file status, re-check now, one-keystroke re-sync that re-downloads the current upstream docs into the extension's docs/ folder). specCheck: false disables even the background refresh.

All tiers share the family mechanics: fire-and-forget, never delays startup, durable verdicts (at most one network pass per 24 h across restarts), silent on network failure (unknown ≠ out of date). The Spec & version check view manages every toggle interactively and shows live per-instance versions beside the verified build and the latest upstream release.

Extension version & provider API

This extension's version (version in package.json) and the lemonade server build it was verified against (11.7.0) are recorded together in the provider block of package.json and in the EXTENSION_CONFIRMED_SERVER_VERSION constant in index.ts (which drives the startup version-drift warning). The server status panel and the Spec & version check view both show the extension version, the live server version(s), and the verified build.

On update, keep these in lockstep (all record the same fact):

  1. version in package.json — the extension release
  2. the provider block in package.json (apiVerifiedAgainst, specSnapshot) — what that release was verified against
  3. EXTENSION_CONFIRMED_SERVER_VERSION in index.ts — the peg the version-drift warning compares your servers against
  4. re-sync docs/ in the extension folder from upstream (the README re-sync command, or /lemonade-setup → Spec & version check)
  5. the behavior-reference dates below

A release that skips any of these is lying somewhere — the spec-freshness and server-version checks (see Keeping the specs honest) are what catch #3 and #4 drifting silently.

Lemonade behavior reference

Empirically confirmed against lemonade 11.7.0 (cross-checked with the official endpoint spec):

  • Auto-loading. A request for an unloaded model triggers an automatic load attempt. If the model's slot is free or holds an unpinned model, lemonade evicts/loads and serves the request. If the slot is occupied by a pinned model, you get slots_pinned_error: "All loaded models of type … are pinned. Unload a model first." — unload or unpin via the setup menu, then retry.
  • Per-type slots. The server holds one slot per model type (llm, transcription, image, embedding, …), so Whisper can be loaded alongside your chat model.
  • change-ctx. Implemented as unload → reload with ctx_size + save_options: true, which persists the choice for future loads.
  • Status panel sources. All five metric endpoints are live-confirmed: /v1/health (incl. per-model is_busy/is_streaming/backend_health/ slot_pool/residency_class/last_use/pid and pinned_models counters), /v1/system-stats, /v1/stats (cumulative *_total counters + per-request prefix-cache hits via cache_tokens), /v1/system-info (hardware inventory
    • model_storage drive usage), and root-level /metrics (Prometheus text; lemonade_llamacpp_* backend gauges incl. requests_processing/ requests_deferred queue depth and n_tokens_max peak sequence length). Caveats seen in the field: the Linux amd_gpu device name can come back as a raw device id (the panel falls back to family), and /metrics requires the bearer key when LEMONADE_API_KEY is set. Power draw in watts is not exposed by any lemonade endpoint — utilization only (*_percent).

Troubleshooting

Symptom Fix
No lemonade-<name>/* models in /model /lemonade-setup → Server settings → Test connection; check baseUrl; run Refresh
Error: slots_pinned_error on first message The pinned model occupies the slot — unload it (Model management), or select the loaded model
transcribe_audio "not available" from the agent Tools need a promptSnippet to appear in the system prompt — that's pi behavior, not a bug here; this extension sets it
Transcription of .mp4 fails with ffmpeg missing Install ffmpeg on the machine running pi (container formats are converted locally before upload)
Discovery finds nothing Expected inside WSL2 (UDP broadcasts don't cross the NAT); set the URL manually
Server unreachable at startup The instance registers nothing — no lemonade models in /model until it's reachable; fix the URL and Refresh
Pull seems to hang It's downloading — the blocking pull path sends no progress. Large models take minutes; the pullTimeoutMs cap guarantees control returns
Status panel shows "(unreachable)" for some sections That one endpoint failed for this box — /metrics in particular requires the API key when the server sets LEMONADE_API_KEY. The rest of the panel stays live
Status panel never refreshes statusPollMs is 0 in the config — that disables live refresh. Set it to a poll interval in ms (default 2000)

MCP Gateway (lemonade as an MCP server)

Lemonade exposes itself as a genuine MCP server at POST /mcp (Streamable HTTP), serving five tools: lemonade_list_models, lemonade_chat, lemonade_transcribe_audio, lemonade_generate_image, and lemonade_omni.

pi's MCP manager only accepts remote MCP endpoints over HTTPS, so this setup ships a tiny local bridge instead:

  • ~/.pi/agent/bin/lemonade-mcp-proxy.mjs — stdio→HTTP proxy: reads newline-delimited JSON-RPC on stdin, forwards each message to the lemonade gateway, writes responses to stdout (SSE frames unwrapped, session id passed through).
  • ~/.pi/agent/mcps-local/lemonade/server.json — MCP manager entry (transport: stdio, connection: lazy, disabled by default).

To use it: open /mcp in pi, pick lemonade, choose Test or refresh, inspect the tool manifest, select the tools you want, and enable the server. The first approved call starts the connection. Note the overlap: these MCP tools duplicate what this extension registers natively — enable them only if you want lemonade tools available in other MCP-consuming setups.