From 9a19fe35301f302fcd8e7506911923269a655cb5 Mon Sep 17 00:00:00 2001 From: Iswarya Alex Date: Wed, 17 Jun 2026 21:53:40 -0700 Subject: [PATCH 01/17] skill: Pass 4 behavioural tests --- skills/local-ai-app-integration/SKILL.md | 226 +++++++++++++++++------ 1 file changed, 168 insertions(+), 58 deletions(-) diff --git a/skills/local-ai-app-integration/SKILL.md b/skills/local-ai-app-integration/SKILL.md index 3f197e5..1849a46 100644 --- a/skills/local-ai-app-integration/SKILL.md +++ b/skills/local-ai-app-integration/SKILL.md @@ -19,6 +19,10 @@ talks to it on `http://localhost:PORT/api/v1`. The user gets local, private, hardware-optimized inference (CPU, AMD iGPU/dGPU, XDNA2 NPU) with no separate install. +**What you'll end up with:** one new launcher module (~30 lines), one config +change to the existing HTTP client (base URL + API key), one vendored binary +under `vendor/lemonade/`. Typical integration: 1–2 hours on a new codebase. + ## When this skill is the right tool Use this skill when **all** of the following are true: @@ -41,10 +45,10 @@ This skill follows one fixed sequence. Do not deviate without a stated reason. ``` [ ] 1. Survey the app's current AI integration [ ] 2. Pick a model + backend profile -[ ] 3. Place Embeddable Lemonade in the app's tree +[ ] 3. Place Embeddable Lemonade in the app's tree (full package, not just the binary) [ ] 4. Add a `lemond` launcher (subprocess + API key + port) -[ ] 5. Re-point the existing client at lemond -[ ] 6. Wait for /v1/health and pre-load the default model +[ ] 5. Re-point the existing client at lemond (set HTTP timeout to 120s) +[ ] 6. Wait for /api/v1/health — do not pre-load; surface first-run latency to user [ ] 7. Wire shutdown and error recovery ``` @@ -100,22 +104,65 @@ For more options and tradeoffs, see [reference.md](reference.md). ## Step 3: Place Embeddable Lemonade in the app's tree and install backends -Get the embeddable artifact from the latest Lemonade release: +**Get the embeddable artifact** from the latest Lemonade release: + +``` +https://github.com/lemonade-sdk/lemonade/releases/latest +``` + +Download the file matching your target OS: - Windows: `lemonade-embeddable-{VERSION}-windows-x64.zip` -- Linux: `lemonade-embeddable-{VERSION}-ubuntu-x64.tar.gz` +- Linux: `lemonade-embeddable-{VERSION}-ubuntu-x64.tar.gz` + +**First, create the target directory** — it does not exist in a fresh repo: + +```powershell +# Windows +New-Item -ItemType Directory -Force vendor\lemonade +``` + +```bash +# Linux +mkdir -p vendor/lemonade +``` + +Then download and unpack on Windows (PowerShell): + +```powershell +$ver = (Invoke-RestMethod https://api.github.com/repos/lemonade-sdk/lemonade/releases/latest).tag_name +Invoke-WebRequest "https://github.com/lemonade-sdk/lemonade/releases/download/$ver/lemonade-embeddable-$ver-windows-x64.zip" -OutFile lemond.zip +Expand-Archive lemond.zip -DestinationPath "$env:TEMP\lemond-unpack" +Copy-Item -Recurse "$env:TEMP\lemond-unpack\lemonade-embeddable-$ver-windows-x64\*" vendor\lemonade\ +``` + +On Linux (bash): -Unpack into the app source tree at `vendor/lemonade/` (or whatever the app's -existing convention for vendored binaries is). The expected layout after -customization: +```bash +VER=$(curl -s https://api.github.com/repos/lemonade-sdk/lemonade/releases/latest | grep tag_name | cut -d'"' -f4) +curl -L "https://github.com/lemonade-sdk/lemonade/releases/download/$VER/lemonade-embeddable-$VER-ubuntu-x64.tar.gz" | tar -xz --strip-components=1 -C vendor/lemonade +``` + +> **Copy the full package, not just the binary.** The archive contains +> `lemond[.exe]`, `lemonade[.exe]`, `LICENSE`, and `resources/`. The +> `resources/` directory is required — without it lemond starts and passes the +> health check but fails on every model and backend request. Copying only the +> binary produces a server that looks healthy but cannot function. + +> **`lemond` vs `lemonade` CLI:** `lemond` is the embedded server binary that +> ships with the app. The `lemonade` CLI is a separate packaging tool used +> only during development/build time to install backends. Install it once on +> the developer machine with `pip install lemonade-sdk`. + +The expected layout after unpacking and customization: ``` vendor/lemonade/ lemond[.exe] # the only binary the app ships LICENSE - config.json # generated on first run + config.json # generated on first run; commit a seed copy resources/ - server_models.json # trim to just the models you ship + server_models.json # do not edit; use GET /api/v1/models at runtime backend_versions.json bin/ # backends bundled at packaging time llamacpp/vulkan/llama-server[.exe] @@ -123,24 +170,41 @@ vendor/lemonade/ models--unsloth--Qwen3-4B-GGUF/ ``` +> **`server_models.json`:** Do not edit or rely on this file. It can be stale. +> The only authoritative model list is `GET /api/v1/models` on a running +> `lemond` instance with the backend already installed. + **Bundle decisions: pick deliberately** - **Backends:** Bundle `llamacpp:vulkan` at packaging time (works on every GPU). Install `llamacpp:rocm` at first run on supported AMD systems via - `POST /v1/install` after probing `GET /v1/system-info`. Never ship every - backend, or the artifact balloons. + `POST /api/v1/install` after probing `GET /api/v1/system-info`. Never ship + every backend, or the artifact balloons. - **Models:** Either bundle the default model under `models/` (offline - install, larger installer) **or** pull on first run with `POST /v1/pull` - (smaller installer, needs network). Pick one and document it. + install, larger installer) **or** pull on first run with + `POST /api/v1/pull` (smaller installer, needs network). Pick one and + document it. - **`models_dir`:** Set to `./models` in `config.json` to keep weights private to the app. Leave as `auto` only if the user explicitly wants to share weights with other apps. -**Install the backend before running any model.** Right after placing -`lemond`, install the backend your chosen recipe needs — a model won't load -without it. Use the CLI at packaging time, e.g. `lemonade backends install -flm:npu` (or `llamacpp:vulkan`, `sd-cpp:cpu`, etc.), or `POST /v1/install` -at first run for hardware-specific backends like `llamacpp:rocm`. +**Backend install timing — two distinct paths:** + +> **Packaging time** (developer machine, before bundling): +> ``` +> lemonade backends install llamacpp:vulkan +> lemonade backends install flm:npu # Windows NPU path only +> ``` +> This bakes the backend binaries into `vendor/lemonade/bin/` before the app +> ships. `lemond` does not need to be running. +> +> **First-run / runtime** (user's machine, after `lemond` is running): +> ```http +> POST /api/v1/install +> {"recipe": "llamacpp", "backend": "rocm"} +> ``` +> Use this for hardware-specific backends (e.g. `llamacpp:rocm`) that cannot +> be bundled universally. `lemond` must already be running (Step 4 complete). ## Step 4: Add a `lemond` launcher @@ -151,6 +215,14 @@ The launcher is a thin process supervisor. Its only jobs: 3. Spawn `lemond --port ` with `LEMONADE_API_KEY` set. 4. Expose the chosen `port` and `key` to the rest of the app. +> **Dev-mode file watchers:** If the app runs with a file watcher (Tauri, +> Electron, Next.js, Vite, etc.) that watches the source tree, ensure +> `vendor/lemonade/` is excluded from the watched paths. Lemond writes config +> and cache files at runtime; a watcher that picks these up will restart the +> app, kill the lemond subprocess, and spawn a new one on a new port — +> silently breaking any in-flight transcription. Add `vendor/` (or the +> equivalent) to the watcher's ignore list before testing. + **Python reference launcher** (adapt to the app's language): ```python @@ -165,17 +237,27 @@ def _free_port() -> int: s.bind(("127.0.0.1", 0)) return s.getsockname()[1] -def start_lemond() -> tuple[subprocess.Popen, str, int]: - port = _free_port() - key = secrets.token_urlsafe(32) - env = {**os.environ, "LEMONADE_API_KEY": key} - proc = subprocess.Popen( - [str(LEMOND_BIN), str(LEMOND_DIR), "--port", str(port)], - env=env, - stdout=subprocess.PIPE, stderr=subprocess.STDOUT, - ) - _wait_for_health(port, key, timeout_s=30) - return proc, key, port +def start_lemond(retries: int = 3) -> tuple[subprocess.Popen, str, int]: + # _free_port releases the socket before lemond binds — another process + # can grab the port in that window. Retry with a fresh port on failure. + last_err: Exception | None = None + for _ in range(retries): + port = _free_port() + key = secrets.token_urlsafe(32) + env = {**os.environ, "LEMONADE_API_KEY": key} + proc = subprocess.Popen( + [str(LEMOND_BIN), str(LEMOND_DIR), "--port", str(port)], + env=env, + stdout=subprocess.PIPE, stderr=subprocess.STDOUT, + ) + try: + _wait_for_health(port, key, timeout_s=30) + return proc, key, port + except RuntimeError as e: + proc.kill() + proc.wait() + last_err = e + raise RuntimeError(f"lemond failed to start after {retries} attempts") from last_err def _wait_for_health(port: int, key: str, timeout_s: int) -> None: url = f"http://127.0.0.1:{port}/api/v1/health" @@ -188,7 +270,7 @@ def _wait_for_health(port: int, key: str, timeout_s: int) -> None: return except Exception: time.sleep(0.25) - raise RuntimeError("lemond failed to become healthy") + raise RuntimeError(f"lemond on port {port} did not become healthy within {timeout_s}s") ``` **Node.js reference launcher:** @@ -208,15 +290,25 @@ const freePort = () => new Promise((res) => { }); }); -export async function startLemond() { - const port = await freePort(); - const key = randomBytes(32).toString("base64url"); - const proc = spawn(LEMOND_BIN, [LEMOND_DIR, "--port", String(port)], { - env: { ...process.env, LEMONADE_API_KEY: key }, - stdio: ["ignore", "pipe", "pipe"], - }); - await waitForHealth(port, key, 30_000); - return { proc, key, port }; +// freePort releases the socket before lemond binds — retry with a fresh port on failure. +export async function startLemond(retries = 3) { + let lastErr; + for (let i = 0; i < retries; i++) { + const port = await freePort(); + const key = randomBytes(32).toString("base64url"); + const proc = spawn(LEMOND_BIN, [LEMOND_DIR, "--port", String(port)], { + env: { ...process.env, LEMONADE_API_KEY: key }, + stdio: ["ignore", "pipe", "pipe"], + }); + try { + await waitForHealth(port, key, 30_000); + return { proc, key, port }; + } catch (e) { + proc.kill(); + lastErr = e; + } + } + throw new Error(`lemond failed to start after ${retries} attempts: ${lastErr?.message}`); } async function waitForHealth(port, key, timeoutMs) { @@ -230,7 +322,7 @@ async function waitForHealth(port, key, timeoutMs) { } catch {} await new Promise((r) => setTimeout(r, 250)); } - throw new Error("lemond failed to become healthy"); + throw new Error(`lemond on port ${port} did not become healthy within ${timeoutMs}ms`); } ``` @@ -258,14 +350,23 @@ internally by the launcher, so the user never enters one and any UI placeholder (e.g. `"local"`) is fine. Flipping into local mode should never strand the user on a key-entry wall. +**Set the HTTP client timeout to at least 120 seconds.** The default timeout +on most HTTP clients (30s) is shorter than the time lemond takes to load a +model on first use. A silent timeout looks identical to a broken integration +— the request fires, nothing comes back, and the UI shows nothing. 120s +covers first-run model load on any supported hardware. + **Python (openai) example:** ```python from openai import OpenAI +import httpx + proc, key, port = start_lemond() client = OpenAI( base_url=f"http://127.0.0.1:{port}/api/v1", api_key=key, + http_client=httpx.Client(timeout=120.0), # covers first-run model load ) resp = client.chat.completions.create( model="Qwen3-4B-GGUF", @@ -273,21 +374,24 @@ resp = client.chat.completions.create( ) ``` -## Step 6: Wait for health, then preload the default model - -`lemond` lazy-loads models on first inference. To eliminate cold-start -latency on the user's first message, preload right after the health check -passes: +## Step 6: Wait for health — do not pre-load -```http -POST /api/v1/load -Authorization: Bearer {key} -Content-Type: application/json +Once `GET /api/v1/health` returns 200, the integration is ready. **Do not +call `POST /api/v1/load` at startup.** Lemond lazy-loads models on the first +inference request and handles this correctly on its own. Pre-loading is +unreliable across lemond versions (request body shape has changed between +releases) and a malformed `/load` call can crash or destabilise the server +before the user takes any action. -{"model": "Qwen3-4B-GGUF"} -``` +**First-run latency is expected and must be surfaced to the user.** On the +very first inference after a cold start, lemond loads the model into memory. +This takes 10–30 seconds depending on model size and hardware. An app that +makes no attempt to communicate this will look broken. -If the model isn't downloaded yet, follow the recovery flow in Step 7. +Minimum: show a loading indicator or status message ("Starting local AI…") +from the moment the user triggers inference until the first response arrives. +The simplest implementation is a flag that is set when the first request is +sent and cleared when the first response arrives. ## Step 7: Lifecycle and recovery @@ -295,10 +399,10 @@ These are the only failure modes worth handling. Do not over-engineer. | Symptom | Cause | Recovery | |---|---|---| -| `POST /v1/load` returns 404 / model not found | Model not pulled yet | `POST /v1/pull` with `{"model": "..."}` then retry `/v1/load` | -| `/v1/load` returns 500 with backend error | Backend not installed for this hardware | `GET /v1/system-info`, pick a supported backend, `POST /v1/install` with `{"recipe": "...", "backend": "..."}`, retry | -| Subprocess exits immediately | Port already in use by another `lemond` | Pick a new free port and retry once | -| `/v1/health` never returns 200 | First-run backend extraction is slow on cold disk | Extend timeout to 90s on first launch, 30s after | +| `POST /api/v1/load` returns 404 / model not found | Model not pulled yet | `POST /api/v1/pull` with `{"model": "..."}` then retry `/api/v1/load` | +| `POST /api/v1/load` returns 500 with backend error | Backend not installed for this hardware | `GET /api/v1/system-info`, pick a supported backend, `POST /api/v1/install` with `{"recipe": "...", "backend": "..."}`, retry | +| Subprocess exits immediately | Port race: another process grabbed the port between `freePort()` and lemond binding | The reference launcher retries with a fresh port automatically (3 attempts) | +| `/api/v1/health` never returns 200 | First-run backend extraction is slow on cold disk | Extend timeout to 90s on first launch, 30s after | | HTTP 401 on every request | Forgot the `Authorization: Bearer` header | Audit the client config because Lemonade rejects unauth'd calls when `LEMONADE_API_KEY` is set | **Shutdown:** On app exit, `proc.terminate()` (Unix) or @@ -314,13 +418,19 @@ couple of seconds. Always wait on the process; never orphan it. The integration is done when **all** of these are true: +- [ ] `vendor/lemonade/` contains the full package: `lemond[.exe]`, + `lemonade[.exe]`, `LICENSE`, and `resources/` — not just the binary. - [ ] `lemond` starts as a subprocess with a fresh API key per launch. - [ ] `GET /api/v1/health` returns 200 within the timeout. -- [ ] The default model loads successfully via `POST /v1/load`. - [ ] The existing client's chat / image / speech call returns a valid response with the base URL and key swapped, with no other code changed. +- [ ] First-run latency is surfaced: the UI shows a loading state from the + moment the first inference request is sent until the response arrives. +- [ ] The HTTP client timeout is set to at least 120 seconds. - [ ] In local mode the app's API-key gate is bypassed: no onboarding wall, validator, or startup check blocks the user for lacking a cloud key. +- [ ] If the app uses a dev-mode file watcher, `vendor/lemonade/` is excluded + from the watched paths so runtime writes by lemond do not trigger restarts. - [ ] Killing the parent process leaves no `lemond` subprocess behind. - [ ] On a fresh machine without the optimal backend, the app still works via the Vulkan fallback bundled in `bin/`. From ae7772fd401ff9a50848f4151553355e72fe5521 Mon Sep 17 00:00:00 2001 From: Iswarya Alex Date: Thu, 16 Jul 2026 21:20:19 -0700 Subject: [PATCH 02/17] fix: Update reference --- skills/local-ai-app-integration/SKILL.md | 13 +------------ 1 file changed, 1 insertion(+), 12 deletions(-) diff --git a/skills/local-ai-app-integration/SKILL.md b/skills/local-ai-app-integration/SKILL.md index 646a13e..9877b56 100644 --- a/skills/local-ai-app-integration/SKILL.md +++ b/skills/local-ai-app-integration/SKILL.md @@ -272,18 +272,7 @@ The launcher is a thin process supervisor. Its only jobs: > silently breaking any in-flight transcription. Add `vendor/` (or the > equivalent) to the watcher's ignore list before testing. -The launcher logic in pseudocode (full Python and Node.js implementations in [reference.md](reference.md#reference-launchers)): - -``` -port = bind("127.0.0.1:0"), read port, close socket -key = random_bytes(32) -proc = spawn(lemond_bin, [lemond_dir, "--port", port], env={LEMONADE_API_KEY: key}) -poll GET /api/v1/health with Bearer key, retry for 90s, 250ms interval -return proc, key, port - -# On failure: kill proc, pick new port, retry up to 3 times -# On app exit: proc.kill() (Windows) / proc.terminate() (Unix), then wait() -``` +**Use the reference implementation from [reference.md § Reference launchers](reference.md#reference-launchers) directly** — copy it verbatim and adapt only the `LEMOND_DIR` path. Do not write a launcher from scratch. The reference Python launcher uses `secrets` (for the API key), `socket` (for the free-port probe), and `subprocess` (to spawn lemond); the Node.js launcher uses the equivalent stdlib modules. Both handle port-race retries and health polling correctly. ## Step 5: Re-point the existing client at `lemond` From ac2d2ece8e99458e6b655955076fdb86add58f06 Mon Sep 17 00:00:00 2001 From: Iswarya Alex Date: Thu, 16 Jul 2026 22:35:49 -0700 Subject: [PATCH 03/17] fix: Update reference --- skills/local-ai-app-integration/SKILL.md | 53 +++++++++++++++++--- skills/local-ai-app-integration/reference.md | 8 +-- 2 files changed, 49 insertions(+), 12 deletions(-) diff --git a/skills/local-ai-app-integration/SKILL.md b/skills/local-ai-app-integration/SKILL.md index 9877b56..89fcf1f 100644 --- a/skills/local-ai-app-integration/SKILL.md +++ b/skills/local-ai-app-integration/SKILL.md @@ -19,8 +19,7 @@ talks to it on `http://localhost:PORT/api/v1`. The user gets local, private, hardware-optimized inference (CPU, AMD iGPU/dGPU, XDNA2 NPU) with no separate install. -**What you'll end up with:** one new launcher module (~30 lines), one config -change to the existing HTTP client (base URL + API key), one vendored binary +**What you'll end up with:** one new launcher module (~30 lines), **three mandatory changes** to the existing HTTP client (`base_url`, `api_key`, and `http_client=httpx.Client(timeout=120)` — all three required together), one vendored binary under `vendor/lemonade/`. ## When this skill is the right tool @@ -47,7 +46,7 @@ This skill follows one fixed sequence. Do not deviate without a stated reason. [ ] 2. Pick a model + backend profile [ ] 3. Place Embeddable Lemonade in the app's tree (full package, not just the binary) [ ] 4. Add a `lemond` launcher (subprocess + API key + port + per-stage logging) -[ ] 5. Re-point the existing client at lemond (set HTTP timeout to 120s) +[ ] 5. Re-point the existing client at lemond — set `base_url`, `api_key`, AND `http_client=httpx.Client(timeout=120)` — all three are required [ ] 6. Wait for /api/v1/health, install backend, then PULL the model before first use [ ] 7. Wire shutdown and error recovery ``` @@ -281,15 +280,36 @@ are required, not optional: 1. Set `base_url` to `http://127.0.0.1:{port}/api/v1` 2. Set `api_key` to the launcher key -3. **Set the HTTP timeout to 120 seconds** — this is mandatory, not optional +3. **Set `http_client=httpx.Client(timeout=120)`** — **required alongside 1 and 2**; writing 1 and 2 without 3 is incomplete and will silently time out on first-run model load + +**Minimal edit when re-pointing an existing file (all three arguments required):** +```python +import httpx +from openai import OpenAI + +client = OpenAI( + base_url="http://127.0.0.1:PORT/api/v1", # replace PORT with the lemond port + api_key="LOCAL_KEY", # replace with the launcher key + http_client=httpx.Client(timeout=120), # required — do not omit +) +``` The 120-second timeout is not a tuning suggestion. The default on most HTTP clients is 30s, which is shorter than lemond's first-run model load time on real hardware. Without it the request silently times out and the UI shows nothing, which is indistinguishable from a broken integration. -**Python (openai) — the exact change to make:** +**Python (openai) — the exact change to make (all three arguments required; omitting `http_client` is a bug):** +**Wrong — `base_url` and `api_key` alone, missing the timeout (will silently time out on first-run model load):** +```python +client = OpenAI( + base_url=f"http://127.0.0.1:{port}/api/v1", + api_key=key, +) # ← missing http_client=httpx.Client(timeout=120) — this is a bug +``` + +**Correct — all three required arguments together:** ```python import httpx from openai import OpenAI @@ -334,7 +354,24 @@ never strand the user on a key-entry wall. ## Step 6: Health, backend, then pull the model — *before* first inference `GET /api/v1/health` returning 200 means the **server** is up. It does **not** -mean inference will work. Before the first real request succeeds, three more +mean inference will work. The correct readiness probe (do not use stdout parsing): + +```python +import urllib.request, time + +def wait_for_health(port: int, key: str, timeout_s: int = 30) -> None: + url = f"http://127.0.0.1:{port}/api/v1/health" + req = urllib.request.Request(url, headers={"Authorization": f"Bearer {key}"}) + deadline = time.monotonic() + timeout_s + while time.monotonic() < deadline: + try: + with urllib.request.urlopen(req, timeout=1) as r: + if r.status == 200: + return + except Exception: + time.sleep(0.25) + raise RuntimeError(f"lemond did not become healthy within {timeout_s}s") +``` Before the first real request succeeds, three more things must be true: the backend for your modality is installed, the model's weights are **downloaded to disk**, and (on the first call) the model is loaded into memory. Treating health=200 as "ready" is the single biggest cause of a @@ -413,7 +450,7 @@ These are the only failure modes worth handling. Do not over-engineer. couple of seconds. Always wait on the process; never orphan it. **Do not** parse `lemond` stdout to detect readiness; use the HTTP -`/v1/health` probe. Stdout format is not a stable contract. +`/api/v1/health` probe. Stdout format is not a stable contract. --- @@ -434,7 +471,7 @@ The integration is done when **all** of these are true: response with the base URL and key swapped, with no other code changed. - [ ] First-run latency is surfaced: the interface shows a loading state from the moment the first inference request is sent until the response arrives. -- [ ] The HTTP client timeout is set to 120 seconds. +- [ ] The OpenAI client is constructed with all three required arguments: `base_url`, `api_key`, and `http_client=httpx.Client(timeout=120)`. A constructor with only `base_url` and `api_key` is incomplete and will silently time out on first-run model load. - [ ] In local mode the app requires **no** cloud API key: no onboarding wall, validator, or startup check blocks the user, and no code path throws "API key not configured" when the active mode is local. diff --git a/skills/local-ai-app-integration/reference.md b/skills/local-ai-app-integration/reference.md index d77f5f1..9226d3f 100644 --- a/skills/local-ai-app-integration/reference.md +++ b/skills/local-ai-app-integration/reference.md @@ -27,7 +27,7 @@ hardware-optimized one at first run after a system probe. | Backend | Hardware | OS | Bundle strategy | |---|---|---|---| | `vulkan` | x86_64 CPU, AMD iGPU/dGPU, most others | Windows, Linux | **Bundle at packaging time.** Universal fallback. | -| `rocm` | gfx1151 (Strix Halo), gfx120X (RDNA4), gfx110X (RDNA3) | Windows, Linux | **Install at first run** if `/v1/system-info` shows `state: installable`. Cannot be packaging-time bundled. | +| `rocm` | gfx1151 (Strix Halo), gfx120X (RDNA4), gfx110X (RDNA3) | Windows, Linux | **Install at first run** if `/api/v1/system-info` shows `state: installable`. Cannot be packaging-time bundled. | | `cpu` | x86_64 CPU | Windows, Linux | Install only if you need a non-Vulkan CPU path. | | `metal` | Apple Silicon | macOS (beta) | macOS-only path. | @@ -80,7 +80,7 @@ ship a default and document how to override. | Text-to-speech | `kokoro-v1` | 0.3 GB | `kokoro` | | Image generation | `SDXL-Turbo` | 6.9 GB | `sd-cpp` | -For a catalog with more models, fetch `GET /v1/models` after starting `lemond`. +For a catalog with more models, fetch `GET /api/v1/models` after starting `lemond`. This is the **only** trusted source of available models. Never read or trust `vendor/lemonade/resources/server_models.json` (or any other static file) as a model catalog; it can be stale or incomplete. A model only appears in @@ -134,7 +134,7 @@ the app's modality. Decision rules in priority order, for the default `llamacpp` recipe (text gen): 1. If `recipes.llamacpp.backends.rocm.state == "installable"` → - `POST /v1/install {"recipe":"llamacpp","backend":"rocm"}`. + `POST /api/v1/install {"recipe":"llamacpp","backend":"rocm"}`. 2. Else if `state == "installed"` for `vulkan` → use it as-is. 3. Else fall back to `cpu`. @@ -267,7 +267,7 @@ Two backend limitations on Linux as of this writing: - `flm` (FastFlowLM, NPU) cannot be bundled at packaging time on Linux. Install at runtime only. - `llamacpp:rocm` cannot be bundled at packaging time on **any** OS. Always - install at runtime via `/v1/install`. + install at runtime via `/api/v1/install`. When building from source for an unusual Linux distro, see the upstream `docs/embeddable/building.md` in the lemonade-sdk/lemonade repo. From 5973dd4f78e475c9554d6c562aa8d833ee93b7d2 Mon Sep 17 00:00:00 2001 From: Iswarya Alex Date: Thu, 16 Jul 2026 22:47:32 -0700 Subject: [PATCH 04/17] test: code ssnippet --- skills/local-ai-app-integration/SKILL.md | 30 +++++++++++++----------- 1 file changed, 16 insertions(+), 14 deletions(-) diff --git a/skills/local-ai-app-integration/SKILL.md b/skills/local-ai-app-integration/SKILL.md index 89fcf1f..1a2c691 100644 --- a/skills/local-ai-app-integration/SKILL.md +++ b/skills/local-ai-app-integration/SKILL.md @@ -241,10 +241,11 @@ vendor/lemonade/ The launcher is a thin process supervisor. Its only jobs: -1. Generate a fresh random API key per app launch. -2. Pick a free localhost port. -3. Spawn `lemond --port ` with `LEMONADE_API_KEY` set. -4. Expose the chosen `port` and `key` to the rest of the app. +1. Generate a fresh random API key: `key = secrets.token_urlsafe(32)` +2. Pick a free localhost port: bind a `socket` to port 0, read back the assigned port, close it. +3. Spawn lemond as a `subprocess`: `subprocess.Popen([LEMOND_BIN, LEMOND_DIR, "--port", str(port)], env={**os.environ, "LEMONADE_API_KEY": key})` +4. Poll `GET /api/v1/health` with `Authorization: Bearer {key}` in a loop until HTTP 200 — this is the only correct readiness check. +5. Expose the chosen `port` and `key` to the rest of the app. > **Log one line per lifecycle stage.** Build the logging in from the start — > not as an afterthought when something breaks. Each silent transition needs a @@ -275,25 +276,26 @@ The launcher is a thin process supervisor. Its only jobs: ## Step 5: Re-point the existing client at `lemond` -Make **three** changes to the app's existing client construction — all three -are required, not optional: +Every re-point of the OpenAI client at lemond requires exactly these three arguments — no exceptions: -1. Set `base_url` to `http://127.0.0.1:{port}/api/v1` -2. Set `api_key` to the launcher key -3. **Set `http_client=httpx.Client(timeout=120)`** — **required alongside 1 and 2**; writing 1 and 2 without 3 is incomplete and will silently time out on first-run model load - -**Minimal edit when re-pointing an existing file (all three arguments required):** ```python import httpx from openai import OpenAI client = OpenAI( - base_url="http://127.0.0.1:PORT/api/v1", # replace PORT with the lemond port - api_key="LOCAL_KEY", # replace with the launcher key - http_client=httpx.Client(timeout=120), # required — do not omit + base_url="http://127.0.0.1:{port}/api/v1", + api_key=key, + http_client=httpx.Client(timeout=120), # required: default 30s times out before first-run model load ) ``` +Make **three** changes to the app's existing client construction — all three +are required, not optional: + +1. Set `base_url="http://127.0.0.1:{port}/api/v1"` +2. Set `api_key=key` +3. Set `http_client=httpx.Client(timeout=120)` — **required, not optional**; the default 30s timeout is shorter than lemond's first-run model load, causing silent failures + The 120-second timeout is not a tuning suggestion. The default on most HTTP clients is 30s, which is shorter than lemond's first-run model load time on real hardware. Without it the request silently times out and the UI shows From 04c5154c6ef1785a5eba1d446b0a6877aef96c95 Mon Sep 17 00:00:00 2001 From: Iswarya Alex Date: Thu, 16 Jul 2026 22:57:02 -0700 Subject: [PATCH 05/17] test: code ssnippet --- skills/local-ai-app-integration/SKILL.md | 45 ++++-------------------- 1 file changed, 7 insertions(+), 38 deletions(-) diff --git a/skills/local-ai-app-integration/SKILL.md b/skills/local-ai-app-integration/SKILL.md index 1a2c691..e12dec1 100644 --- a/skills/local-ai-app-integration/SKILL.md +++ b/skills/local-ai-app-integration/SKILL.md @@ -276,53 +276,22 @@ The launcher is a thin process supervisor. Its only jobs: ## Step 5: Re-point the existing client at `lemond` -Every re-point of the OpenAI client at lemond requires exactly these three arguments — no exceptions: +When the app has `client = OpenAI()`, replace it with exactly this — three arguments, no exceptions: ```python -import httpx -from openai import OpenAI +# Before (cloud): +# client = OpenAI() +# After (lemond) — all three arguments are required: +import httpx client = OpenAI( base_url="http://127.0.0.1:{port}/api/v1", api_key=key, - http_client=httpx.Client(timeout=120), # required: default 30s times out before first-run model load + http_client=httpx.Client(timeout=120), ) ``` -Make **three** changes to the app's existing client construction — all three -are required, not optional: - -1. Set `base_url="http://127.0.0.1:{port}/api/v1"` -2. Set `api_key=key` -3. Set `http_client=httpx.Client(timeout=120)` — **required, not optional**; the default 30s timeout is shorter than lemond's first-run model load, causing silent failures - -The 120-second timeout is not a tuning suggestion. The default on most HTTP -clients is 30s, which is shorter than lemond's first-run model load time on -real hardware. Without it the request silently times out and the UI shows -nothing, which is indistinguishable from a broken integration. - -**Python (openai) — the exact change to make (all three arguments required; omitting `http_client` is a bug):** - -**Wrong — `base_url` and `api_key` alone, missing the timeout (will silently time out on first-run model load):** -```python -client = OpenAI( - base_url=f"http://127.0.0.1:{port}/api/v1", - api_key=key, -) # ← missing http_client=httpx.Client(timeout=120) — this is a bug -``` - -**Correct — all three required arguments together:** -```python -import httpx -from openai import OpenAI - -proc, key, port = start_lemond() -client = OpenAI( - base_url=f"http://127.0.0.1:{port}/api/v1", - api_key=key, - http_client=httpx.Client(timeout=120), # required: 120s for first-run model load -) -``` +`http_client=httpx.Client(timeout=120)` is **not optional**. The default HTTP timeout is 30 seconds, which is less than lemond's first-run model load time. Omitting it causes the request to silently time out with no error — the single most common integration failure. For other clients: From 8c2b56c38cb6b274184fee55e9063225205aec91 Mon Sep 17 00:00:00 2001 From: Iswarya Alex Date: Thu, 16 Jul 2026 23:08:31 -0700 Subject: [PATCH 06/17] test: ensure the test usesthe skill --- skills/local-ai-app-integration/SKILL.md | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/skills/local-ai-app-integration/SKILL.md b/skills/local-ai-app-integration/SKILL.md index e12dec1..2affaa0 100644 --- a/skills/local-ai-app-integration/SKILL.md +++ b/skills/local-ai-app-integration/SKILL.md @@ -2,8 +2,12 @@ name: local-ai-app-integration description: >- Integrates local AI capabilities into applications using Embeddable Lemonade. - Use when the user wants to add local AI, offline AI, private AI, on-device AI, - a local LLM, local chat, embeddings, image generation, speech-to-text, or + ALWAYS use this skill when the task involves lemond or lemonade in any way — + writing a lemond launcher, writing a lemond launcher module, re-pointing an + OpenAI client at lemond, writing a health-check for lemond, editing an app to + work with lemond, or any task that mentions lemond or lemonade. Also use when + the user wants to add local AI, offline AI, private AI, on-device AI, a local + LLM, local chat, embeddings, image generation, speech-to-text, or text-to-speech to an app; replace or supplement OpenAI, Anthropic, Ollama, or other cloud AI APIs with a local backend; bundle AI inference into an app installer; or mentions Lemonade, `lemond`, embeddable lemonade, Ryzen AI, From 08c698e998ff030865c3c5af9e25a01d220f144e Mon Sep 17 00:00:00 2001 From: Iswarya Alex Date: Thu, 16 Jul 2026 23:17:53 -0700 Subject: [PATCH 07/17] test verbose --- skills/local-ai-app-integration/SKILL.md | 4 ++-- skills/local-ai-app-integration/evals/evals.py | 7 +++++++ 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/skills/local-ai-app-integration/SKILL.md b/skills/local-ai-app-integration/SKILL.md index 2affaa0..9afa403 100644 --- a/skills/local-ai-app-integration/SKILL.md +++ b/skills/local-ai-app-integration/SKILL.md @@ -287,7 +287,7 @@ When the app has `client = OpenAI()`, replace it with exactly this — three arg # client = OpenAI() # After (lemond) — all three arguments are required: -import httpx +import httpx # httpx is already installed: it is a dependency of the openai package client = OpenAI( base_url="http://127.0.0.1:{port}/api/v1", api_key=key, @@ -295,7 +295,7 @@ client = OpenAI( ) ``` -`http_client=httpx.Client(timeout=120)` is **not optional**. The default HTTP timeout is 30 seconds, which is less than lemond's first-run model load time. Omitting it causes the request to silently time out with no error — the single most common integration failure. +`http_client=httpx.Client(timeout=120)` is **not optional**. `httpx` requires no installation — it ships with the `openai` package. The default HTTP timeout is 30 seconds, which is shorter than lemond's first-run model load time. Omitting it causes the request to silently time out with no error — the single most common integration failure. For other clients: diff --git a/skills/local-ai-app-integration/evals/evals.py b/skills/local-ai-app-integration/evals/evals.py index 0536dd9..46a899a 100644 --- a/skills/local-ai-app-integration/evals/evals.py +++ b/skills/local-ai-app-integration/evals/evals.py @@ -30,6 +30,10 @@ def test_launcher_module_written(): "Do not download or install anything — just write the file." ) + launcher = agent.workspace / "lemond_launcher.py" + print(f"\n[debug] lemond_launcher.py contents:\n{launcher.read_text() if launcher.is_file() else '(file not found)'}", flush=True) + print(f"[debug] agent final message:\n{run.result_text}", flush=True) + run.workspace_contains("lemond_launcher.py") run.logs_contains("secrets") # random API key generation run.logs_contains("socket") # dynamic port via socket bind @@ -45,6 +49,9 @@ def test_http_client_timeout_is_120s(): "instance. Do not download or install anything — just edit the file." ) + print(f"\n[debug] main.py contents:\n{(agent.workspace / 'main.py').read_text()}", flush=True) + print(f"[debug] agent final message:\n{run.result_text}", flush=True) + run.workspace_contains("main.py") run.logs_contains("120") # 120s timeout present in written code From d2c3499299f80667aac81a47d6a452da23bd9f4a Mon Sep 17 00:00:00 2001 From: Iswarya Alex Date: Thu, 16 Jul 2026 23:28:52 -0700 Subject: [PATCH 08/17] test: ensure skill invocation --- .../local-ai-app-integration/evals/evals.py | 22 +++++++++---------- 1 file changed, 10 insertions(+), 12 deletions(-) diff --git a/skills/local-ai-app-integration/evals/evals.py b/skills/local-ai-app-integration/evals/evals.py index 46a899a..cfa7e28 100644 --- a/skills/local-ai-app-integration/evals/evals.py +++ b/skills/local-ai-app-integration/evals/evals.py @@ -27,13 +27,10 @@ def test_launcher_module_written(): run = agent.prompt( "Write a lemond launcher module for this Python app. " - "Do not download or install anything — just write the file." + "Do not download or install anything — just write the file. " + "Use the local-ai-app-integration skill." ) - launcher = agent.workspace / "lemond_launcher.py" - print(f"\n[debug] lemond_launcher.py contents:\n{launcher.read_text() if launcher.is_file() else '(file not found)'}", flush=True) - print(f"[debug] agent final message:\n{run.result_text}", flush=True) - run.workspace_contains("lemond_launcher.py") run.logs_contains("secrets") # random API key generation run.logs_contains("socket") # dynamic port via socket bind @@ -46,12 +43,10 @@ def test_http_client_timeout_is_120s(): run = agent.prompt( "Update main.py to re-point the OpenAI client at a local lemond " - "instance. Do not download or install anything — just edit the file." + "instance. Do not download or install anything — just edit the file. " + "Use the local-ai-app-integration skill." ) - print(f"\n[debug] main.py contents:\n{(agent.workspace / 'main.py').read_text()}", flush=True) - print(f"[debug] agent final message:\n{run.result_text}", flush=True) - run.workspace_contains("main.py") run.logs_contains("120") # 120s timeout present in written code @@ -62,7 +57,8 @@ def test_health_check_uses_http_not_stdout(): run = agent.prompt( "Write a health-check helper for lemond in this Python app. " - "Do not download or install anything — just write the code." + "Do not download or install anything — just write the code. " + "Use the local-ai-app-integration skill." ) run.logs_contains("/api/v1/health") @@ -75,7 +71,8 @@ def test_no_preload_call_in_written_code(): run = agent.prompt( "Write a lemond launcher for this Python app that waits for the " - "server to be ready. Do not download or install anything." + "server to be ready. Do not download or install anything. " + "Use the local-ai-app-integration skill." ) run.logs_contains("/api/v1/health") @@ -95,7 +92,8 @@ def test_api_key_gate_bypassed_in_local_mode(): run = agent.prompt( "Edit main.py so it works in local mode without an OPENAI_API_KEY. " - "Do not download or install anything — just edit the file." + "Do not download or install anything — just edit the file. " + "Use the local-ai-app-integration skill." ) run.workspace_contains("main.py") From 2435d48b1dd88ba8a29e09cacdf45475009ff9c8 Mon Sep 17 00:00:00 2001 From: Iswarya Alex Date: Thu, 16 Jul 2026 23:42:06 -0700 Subject: [PATCH 09/17] fix: tests passing --- eval/behavioral/harness.py | 9 +++++++++ .../local-ai-app-integration/evals/evals.py | 20 +++++++++---------- 2 files changed, 19 insertions(+), 10 deletions(-) diff --git a/eval/behavioral/harness.py b/eval/behavioral/harness.py index dbd7459..b4850a6 100644 --- a/eval/behavioral/harness.py +++ b/eval/behavioral/harness.py @@ -129,6 +129,15 @@ def _stage_workspace(skill: str) -> Path: dest = workspace / ".claude" / "skills" / skill dest.parent.mkdir(parents=True, exist_ok=True) shutil.copytree(skill_src, dest) + + # Write a CLAUDE.md that instructs the agent to use the skill for every + # task in this workspace. The CLI reads CLAUDE.md automatically at startup, + # so this guarantees skill invocation without requiring each test prompt to + # mention it explicitly. + (workspace / "CLAUDE.md").write_text( + f"For every task in this workspace, use the `{skill}` skill.\n", + encoding="utf-8", + ) return workspace diff --git a/skills/local-ai-app-integration/evals/evals.py b/skills/local-ai-app-integration/evals/evals.py index cfa7e28..b9a7dee 100644 --- a/skills/local-ai-app-integration/evals/evals.py +++ b/skills/local-ai-app-integration/evals/evals.py @@ -27,10 +27,10 @@ def test_launcher_module_written(): run = agent.prompt( "Write a lemond launcher module for this Python app. " - "Do not download or install anything — just write the file. " - "Use the local-ai-app-integration skill." + "Do not download or install anything — just write the file." ) + run.logs_contains("local-ai-app-integration") # skill was invoked run.workspace_contains("lemond_launcher.py") run.logs_contains("secrets") # random API key generation run.logs_contains("socket") # dynamic port via socket bind @@ -43,10 +43,10 @@ def test_http_client_timeout_is_120s(): run = agent.prompt( "Update main.py to re-point the OpenAI client at a local lemond " - "instance. Do not download or install anything — just edit the file. " - "Use the local-ai-app-integration skill." + "instance. Do not download or install anything — just edit the file." ) + run.logs_contains("local-ai-app-integration") # skill was invoked run.workspace_contains("main.py") run.logs_contains("120") # 120s timeout present in written code @@ -57,10 +57,10 @@ def test_health_check_uses_http_not_stdout(): run = agent.prompt( "Write a health-check helper for lemond in this Python app. " - "Do not download or install anything — just write the code. " - "Use the local-ai-app-integration skill." + "Do not download or install anything — just write the code." ) + run.logs_contains("local-ai-app-integration") # skill was invoked run.logs_contains("/api/v1/health") run.should_not("Read or parse lemond's stdout or stderr to detect readiness") @@ -71,10 +71,10 @@ def test_no_preload_call_in_written_code(): run = agent.prompt( "Write a lemond launcher for this Python app that waits for the " - "server to be ready. Do not download or install anything. " - "Use the local-ai-app-integration skill." + "server to be ready. Do not download or install anything." ) + run.logs_contains("local-ai-app-integration") # skill was invoked run.logs_contains("/api/v1/health") run.should_not("Call POST /api/v1/load to pre-load the model at startup") @@ -92,10 +92,10 @@ def test_api_key_gate_bypassed_in_local_mode(): run = agent.prompt( "Edit main.py so it works in local mode without an OPENAI_API_KEY. " - "Do not download or install anything — just edit the file. " - "Use the local-ai-app-integration skill." + "Do not download or install anything — just edit the file." ) + run.logs_contains("local-ai-app-integration") # skill was invoked run.workspace_contains("main.py") run.should( "Remove or bypass the API-key guard so the app starts in local mode " From a5c358636f1806f628aab7ac18fc4c5266002d25 Mon Sep 17 00:00:00 2001 From: Iswarya Alex Date: Fri, 17 Jul 2026 00:01:04 -0700 Subject: [PATCH 10/17] skill: cleanup --- skills/local-ai-app-integration/SKILL.md | 46 ++++++++++-------------- 1 file changed, 19 insertions(+), 27 deletions(-) diff --git a/skills/local-ai-app-integration/SKILL.md b/skills/local-ai-app-integration/SKILL.md index 9afa403..aa9762a 100644 --- a/skills/local-ai-app-integration/SKILL.md +++ b/skills/local-ai-app-integration/SKILL.md @@ -280,23 +280,32 @@ The launcher is a thin process supervisor. Its only jobs: ## Step 5: Re-point the existing client at `lemond` -When the app has `client = OpenAI()`, replace it with exactly this — three arguments, no exceptions: +Make **three** changes to the app's existing client construction — all three +are required, not optional: + +1. Set `base_url="http://127.0.0.1:{port}/api/v1"` +2. Set `api_key=key` +3. Set `http_client=httpx.Client(timeout=120)` — **required, not optional**; the default 30s timeout is shorter than lemond's first-run model load, causing silent failures + +The 120-second timeout is not a tuning suggestion. The default on most HTTP +clients is 30s, which is shorter than lemond's first-run model load time on +real hardware. Without it the request silently times out and the UI shows +nothing, which is indistinguishable from a broken integration. + +**Python (openai) — the exact change to make:** ```python -# Before (cloud): -# client = OpenAI() +import httpx +from openai import OpenAI -# After (lemond) — all three arguments are required: -import httpx # httpx is already installed: it is a dependency of the openai package +proc, key, port = start_lemond() client = OpenAI( - base_url="http://127.0.0.1:{port}/api/v1", + base_url=f"http://127.0.0.1:{port}/api/v1", api_key=key, - http_client=httpx.Client(timeout=120), + http_client=httpx.Client(timeout=120), # required: 120s for first-run model load ) ``` -`http_client=httpx.Client(timeout=120)` is **not optional**. `httpx` requires no installation — it ships with the `openai` package. The default HTTP timeout is 30 seconds, which is shorter than lemond's first-run model load time. Omitting it causes the request to silently time out with no error — the single most common integration failure. - For other clients: | Existing client | New `base_url` | New auth | Timeout | @@ -329,24 +338,7 @@ never strand the user on a key-entry wall. ## Step 6: Health, backend, then pull the model — *before* first inference `GET /api/v1/health` returning 200 means the **server** is up. It does **not** -mean inference will work. The correct readiness probe (do not use stdout parsing): - -```python -import urllib.request, time - -def wait_for_health(port: int, key: str, timeout_s: int = 30) -> None: - url = f"http://127.0.0.1:{port}/api/v1/health" - req = urllib.request.Request(url, headers={"Authorization": f"Bearer {key}"}) - deadline = time.monotonic() + timeout_s - while time.monotonic() < deadline: - try: - with urllib.request.urlopen(req, timeout=1) as r: - if r.status == 200: - return - except Exception: - time.sleep(0.25) - raise RuntimeError(f"lemond did not become healthy within {timeout_s}s") -``` Before the first real request succeeds, three more +mean inference will work. Before the first real request succeeds, three more things must be true: the backend for your modality is installed, the model's weights are **downloaded to disk**, and (on the first call) the model is loaded into memory. Treating health=200 as "ready" is the single biggest cause of a From 9f43655ad3948a4320b6c3aec081fa0b7b57d7ea Mon Sep 17 00:00:00 2001 From: Iswarya Alex Date: Fri, 17 Jul 2026 00:05:58 -0700 Subject: [PATCH 11/17] skill: cleanup --- skills/local-ai-app-integration/SKILL.md | 21 ++++++++------------- 1 file changed, 8 insertions(+), 13 deletions(-) diff --git a/skills/local-ai-app-integration/SKILL.md b/skills/local-ai-app-integration/SKILL.md index aa9762a..4f6965b 100644 --- a/skills/local-ai-app-integration/SKILL.md +++ b/skills/local-ai-app-integration/SKILL.md @@ -2,12 +2,8 @@ name: local-ai-app-integration description: >- Integrates local AI capabilities into applications using Embeddable Lemonade. - ALWAYS use this skill when the task involves lemond or lemonade in any way — - writing a lemond launcher, writing a lemond launcher module, re-pointing an - OpenAI client at lemond, writing a health-check for lemond, editing an app to - work with lemond, or any task that mentions lemond or lemonade. Also use when - the user wants to add local AI, offline AI, private AI, on-device AI, a local - LLM, local chat, embeddings, image generation, speech-to-text, or + Use when the user wants to add local AI, offline AI, private AI, on-device AI, + a local LLM, local chat, embeddings, image generation, speech-to-text, or text-to-speech to an app; replace or supplement OpenAI, Anthropic, Ollama, or other cloud AI APIs with a local backend; bundle AI inference into an app installer; or mentions Lemonade, `lemond`, embeddable lemonade, Ryzen AI, @@ -23,8 +19,7 @@ talks to it on `http://localhost:PORT/api/v1`. The user gets local, private, hardware-optimized inference (CPU, AMD iGPU/dGPU, XDNA2 NPU) with no separate install. -**What you'll end up with:** one new launcher module (~30 lines), **three mandatory changes** to the existing HTTP client (`base_url`, `api_key`, and `http_client=httpx.Client(timeout=120)` — all three required together), one vendored binary -under `vendor/lemonade/`. +**What you'll end up with:** one new launcher module (~30 lines), three mandatory changes to the existing HTTP client (`base_url`, `api_key`, and a 120-second HTTP timeout), one vendored binary under `vendor/lemonade/`. ## When this skill is the right tool @@ -50,7 +45,7 @@ This skill follows one fixed sequence. Do not deviate without a stated reason. [ ] 2. Pick a model + backend profile [ ] 3. Place Embeddable Lemonade in the app's tree (full package, not just the binary) [ ] 4. Add a `lemond` launcher (subprocess + API key + port + per-stage logging) -[ ] 5. Re-point the existing client at lemond — set `base_url`, `api_key`, AND `http_client=httpx.Client(timeout=120)` — all three are required +[ ] 5. Re-point the existing client at lemond (base_url, api_key, 120s timeout — all three required) [ ] 6. Wait for /api/v1/health, install backend, then PULL the model before first use [ ] 7. Wire shutdown and error recovery ``` @@ -283,9 +278,9 @@ The launcher is a thin process supervisor. Its only jobs: Make **three** changes to the app's existing client construction — all three are required, not optional: -1. Set `base_url="http://127.0.0.1:{port}/api/v1"` -2. Set `api_key=key` -3. Set `http_client=httpx.Client(timeout=120)` — **required, not optional**; the default 30s timeout is shorter than lemond's first-run model load, causing silent failures +1. Set `base_url` to `http://127.0.0.1:{port}/api/v1` +2. Set `api_key` to the launcher key +3. **Set the HTTP timeout to 120 seconds** — this is mandatory, not optional The 120-second timeout is not a tuning suggestion. The default on most HTTP clients is 30s, which is shorter than lemond's first-run model load time on @@ -438,7 +433,7 @@ The integration is done when **all** of these are true: response with the base URL and key swapped, with no other code changed. - [ ] First-run latency is surfaced: the interface shows a loading state from the moment the first inference request is sent until the response arrives. -- [ ] The OpenAI client is constructed with all three required arguments: `base_url`, `api_key`, and `http_client=httpx.Client(timeout=120)`. A constructor with only `base_url` and `api_key` is incomplete and will silently time out on first-run model load. +- [ ] The HTTP client timeout is set to 120 seconds. - [ ] In local mode the app requires **no** cloud API key: no onboarding wall, validator, or startup check blocks the user, and no code path throws "API key not configured" when the active mode is local. From 342434562db89d4c7b538f8c2f4cadf1516d6010 Mon Sep 17 00:00:00 2001 From: Iswarya Alex Date: Fri, 17 Jul 2026 00:22:03 -0700 Subject: [PATCH 12/17] Update harness --- eval/behavioral/harness.py | 9 --------- 1 file changed, 9 deletions(-) diff --git a/eval/behavioral/harness.py b/eval/behavioral/harness.py index b4850a6..dbd7459 100644 --- a/eval/behavioral/harness.py +++ b/eval/behavioral/harness.py @@ -129,15 +129,6 @@ def _stage_workspace(skill: str) -> Path: dest = workspace / ".claude" / "skills" / skill dest.parent.mkdir(parents=True, exist_ok=True) shutil.copytree(skill_src, dest) - - # Write a CLAUDE.md that instructs the agent to use the skill for every - # task in this workspace. The CLI reads CLAUDE.md automatically at startup, - # so this guarantees skill invocation without requiring each test prompt to - # mention it explicitly. - (workspace / "CLAUDE.md").write_text( - f"For every task in this workspace, use the `{skill}` skill.\n", - encoding="utf-8", - ) return workspace From 728a1d7e5a9eaf59d8055b00aaf6fe6ef6e91ce5 Mon Sep 17 00:00:00 2001 From: Iswarya Alex Date: Fri, 17 Jul 2026 00:30:07 -0700 Subject: [PATCH 13/17] Fix: Reproducible error getting fixed --- eval/behavioral/harness.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/eval/behavioral/harness.py b/eval/behavioral/harness.py index dbd7459..0548052 100644 --- a/eval/behavioral/harness.py +++ b/eval/behavioral/harness.py @@ -129,6 +129,13 @@ def _stage_workspace(skill: str) -> Path: dest = workspace / ".claude" / "skills" / skill dest.parent.mkdir(parents=True, exist_ok=True) shutil.copytree(skill_src, dest) + + # Ensures the skill body is loaded rather than the agent answering from + # training knowledge without invoking the skill. + (workspace / "CLAUDE.md").write_text( + f"For every task in this workspace, use the `{skill}` skill.\n", + encoding="utf-8", + ) return workspace From 8b703615394108b99c7a962ba05abac310e46a16 Mon Sep 17 00:00:00 2001 From: Iswarya Alex Date: Fri, 17 Jul 2026 11:21:57 -0700 Subject: [PATCH 14/17] remove harness change and force unit test --- eval/behavioral/harness.py | 6 ------ .../local-ai-app-integration/evals/evals.py | 19 ++++++++++++------- 2 files changed, 12 insertions(+), 13 deletions(-) diff --git a/eval/behavioral/harness.py b/eval/behavioral/harness.py index 0548052..da624b4 100644 --- a/eval/behavioral/harness.py +++ b/eval/behavioral/harness.py @@ -130,12 +130,6 @@ def _stage_workspace(skill: str) -> Path: dest.parent.mkdir(parents=True, exist_ok=True) shutil.copytree(skill_src, dest) - # Ensures the skill body is loaded rather than the agent answering from - # training knowledge without invoking the skill. - (workspace / "CLAUDE.md").write_text( - f"For every task in this workspace, use the `{skill}` skill.\n", - encoding="utf-8", - ) return workspace diff --git a/skills/local-ai-app-integration/evals/evals.py b/skills/local-ai-app-integration/evals/evals.py index b9a7dee..6131183 100644 --- a/skills/local-ai-app-integration/evals/evals.py +++ b/skills/local-ai-app-integration/evals/evals.py @@ -26,11 +26,12 @@ def test_launcher_module_written(): (agent.workspace / "main.py").write_text(_STUB) run = agent.prompt( - "Write a lemond launcher module for this Python app. " - "Do not download or install anything — just write the file." + "Help me add local AI to this Python app using embeddable lemonade " + "so it replaces the OpenAI API with a local backend. " + "Do not download or install anything — just write the launcher file." ) - run.logs_contains("local-ai-app-integration") # skill was invoked + run.logs_contains("local-ai-app-integration") # skill triggered by description run.workspace_contains("lemond_launcher.py") run.logs_contains("secrets") # random API key generation run.logs_contains("socket") # dynamic port via socket bind @@ -43,7 +44,8 @@ def test_http_client_timeout_is_120s(): run = agent.prompt( "Update main.py to re-point the OpenAI client at a local lemond " - "instance. Do not download or install anything — just edit the file." + "instance. Do not download or install anything — just edit the file. " + "Use the local-ai-app-integration skill." ) run.logs_contains("local-ai-app-integration") # skill was invoked @@ -57,7 +59,8 @@ def test_health_check_uses_http_not_stdout(): run = agent.prompt( "Write a health-check helper for lemond in this Python app. " - "Do not download or install anything — just write the code." + "Do not download or install anything — just write the code. " + "Use the local-ai-app-integration skill." ) run.logs_contains("local-ai-app-integration") # skill was invoked @@ -71,7 +74,8 @@ def test_no_preload_call_in_written_code(): run = agent.prompt( "Write a lemond launcher for this Python app that waits for the " - "server to be ready. Do not download or install anything." + "server to be ready. Do not download or install anything. " + "Use the local-ai-app-integration skill." ) run.logs_contains("local-ai-app-integration") # skill was invoked @@ -92,7 +96,8 @@ def test_api_key_gate_bypassed_in_local_mode(): run = agent.prompt( "Edit main.py so it works in local mode without an OPENAI_API_KEY. " - "Do not download or install anything — just edit the file." + "Do not download or install anything — just edit the file. " + "Use the local-ai-app-integration skill." ) run.logs_contains("local-ai-app-integration") # skill was invoked From 9314cff653a0a1f5c53cc8372a668b34544471c6 Mon Sep 17 00:00:00 2001 From: Iswarya Alex Date: Fri, 17 Jul 2026 11:48:56 -0700 Subject: [PATCH 15/17] remove harness change and force unit test --- skills/local-ai-app-integration/evals/evals.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/skills/local-ai-app-integration/evals/evals.py b/skills/local-ai-app-integration/evals/evals.py index 6131183..ad4c666 100644 --- a/skills/local-ai-app-integration/evals/evals.py +++ b/skills/local-ai-app-integration/evals/evals.py @@ -28,7 +28,8 @@ def test_launcher_module_written(): run = agent.prompt( "Help me add local AI to this Python app using embeddable lemonade " "so it replaces the OpenAI API with a local backend. " - "Do not download or install anything — just write the launcher file." + "Do not download or install anything — just write the launcher file " + "as lemond_launcher.py." ) run.logs_contains("local-ai-app-integration") # skill triggered by description From d5b5c2d5950d2120c21777477e68bd4a192d1dfe Mon Sep 17 00:00:00 2001 From: Iswarya Alex Date: Fri, 17 Jul 2026 13:45:38 -0700 Subject: [PATCH 16/17] implementation test --- skills/local-ai-app-integration/evals/evals.py | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/skills/local-ai-app-integration/evals/evals.py b/skills/local-ai-app-integration/evals/evals.py index ad4c666..4c9a7b2 100644 --- a/skills/local-ai-app-integration/evals/evals.py +++ b/skills/local-ai-app-integration/evals/evals.py @@ -28,15 +28,14 @@ def test_launcher_module_written(): run = agent.prompt( "Help me add local AI to this Python app using embeddable lemonade " "so it replaces the OpenAI API with a local backend. " - "Do not download or install anything — just write the launcher file " - "as lemond_launcher.py." + "Do not download or install anything — just write the launcher file." ) run.logs_contains("local-ai-app-integration") # skill triggered by description - run.workspace_contains("lemond_launcher.py") - run.logs_contains("secrets") # random API key generation - run.logs_contains("socket") # dynamic port via socket bind - run.logs_contains("subprocess") # lemond spawned as subprocess + run.should("Write a lemond launcher module as a new Python file") + run.should("Generate a random API key per launch") + run.should("Pick a free port dynamically") + run.should("Spawn lemond as a subprocess") def test_http_client_timeout_is_120s(): From 799113bc75f1f3b00711758b241e1bb7f237bdd9 Mon Sep 17 00:00:00 2001 From: Iswarya Alex Date: Fri, 17 Jul 2026 14:13:54 -0700 Subject: [PATCH 17/17] reporducible --- .../local-ai-app-integration/evals/evals.py | 20 +++++++++++++++---- 1 file changed, 16 insertions(+), 4 deletions(-) diff --git a/skills/local-ai-app-integration/evals/evals.py b/skills/local-ai-app-integration/evals/evals.py index 4c9a7b2..6f91b21 100644 --- a/skills/local-ai-app-integration/evals/evals.py +++ b/skills/local-ai-app-integration/evals/evals.py @@ -32,10 +32,22 @@ def test_launcher_module_written(): ) run.logs_contains("local-ai-app-integration") # skill triggered by description - run.should("Write a lemond launcher module as a new Python file") - run.should("Generate a random API key per launch") - run.should("Pick a free port dynamically") - run.should("Spawn lemond as a subprocess") + + +def test_launcher_implementation(): + with claude("opus", skill="local-ai-app-integration") as agent: + (agent.workspace / "main.py").write_text(_STUB) + + run = agent.prompt( + "Write a lemond launcher module for this Python app. " + "Do not download or install anything — just write the file. " + "Use the local-ai-app-integration skill." + ) + + run.logs_contains("local-ai-app-integration") # skill was invoked + run.logs_contains("secrets") # random API key generation + run.logs_contains("socket") # dynamic port via socket bind + run.logs_contains("subprocess") # lemond spawned as subprocess def test_http_client_timeout_is_120s():