Skip to content
CSuite
EngineeringLocal AIAI ModelsAugust 8, 20269 min read

How one app talks to both cloud AI and local AI

One button, four backends: how a desktop app makes Replicate, Runware, Ollama, and in-process ONNX behave like one thing. Real tradeoffs inside.

The routing table behind one Generate button
Every AI app eventually becomes a router. The question is whether it’s a catalog or an if-else pyramid.
ReplicateCloud APIHTTPS · prediction lifecycleRented GPUs, polled or streamed
RunwareCloud APIWebSocket · task UUIDsPersistent socket, async tasks
OllamaLocal runtimeHTTP on localhostA server the app spawns itself
transformers.jsLocal runtimeIn-process · ONNXNo server, no network at all
The four inference backends behind CSuite’s one Generate button, August 2026. One catalog file describes all of them; the rest of this post is how.

The user picks a model, types a prompt, and hits Generate. Behind that one button, our desktop app might open an HTTPS connection to a GPU rented from Replicate, or push a task down a WebSocket to Runware, or call an HTTP server on localhost that it spawned itself, or make no connection at all and run an ONNX model inside its own process. Same button, same progress bar, same file in the project folder at the end. Four backends that agree on almost nothing.

This post is the engineering tour of how one app, our own CSuite, treats cloud APIs and local runtimes as the same thing behind one interface. It is told through the five problems that actually made it hard: describing the models, routing the request, filtering the parameters, streaming the result, and keeping the trust boundaries intact. The pattern matters beyond this product. Every AI app that outgrows a single provider becomes a router, and the design question is what keeps the router from collapsing into a pyramid of special cases.

A “model” means four different machines

Start with how little the four backends share. Replicate is a classic cloud API: you create a prediction over HTTPS, it moves through statuses (starting, processing, succeeded, failed), you poll or stream, and a run can take up to 30 minutes. Runware prefers a persistent WebSocket: you authenticate once per connection, fire tasks tagged with UUIDs, and match responses back by tag as they arrive out of order.

The local half is stranger. Ollama is an HTTP server that happens to live on your own machine; CSuite bundles its own copy and runs it on port 11435, one off from Ollama’s default 11434, so it never fights an install the user already has. transformers.js removes the server entirely: it runs ONNX-exported models through ONNX Runtime inside a child process of the app. No socket, no port, no network.

The asymmetries go all the way down. Cloud models need an API key; local ones need gigabytes of disk and enough RAM. Cloud failures look like rate limits and timeouts; local failures look like out-of-memory kills. A cloud model is available the moment you know its name; a local model has to be downloaded, version-pinned, and health-checked before the first token. An abstraction that pretends these differences don’t exist will leak them at the worst moments. The goal is to contain them in known places instead.

The two halves of the same Generate button: someone else’s datacenter, or the machine on your desk. Illustration generated with Seedream 4.5 via Runware.

One catalog beats four model lists

The tempting first design is one model list per provider: the Replicate models, the Runware models, the Ollama models. It works for a demo and then rots. The same model appears twice with different names, every picker needs merge logic, and every new provider multiplies the surfaces to update.

CSuite inverted it: one catalog file, organized by modality, where each model is a single entry that declares which platforms can run it. As of August 2026 that file describes 107 models (62 text, 17 image, 13 audio, 15 video) through 151 platform blocks: 49 on Replicate, 49 on Runware, 47 on Ollama, 6 on the embedded HuggingFace runtime. A model offered by two clouds is one entry with two blocks, not two rows. A small model that runs on both local runtimes is also one entry: the catalog id is the Ollama tag, and the block for the HF runtime points at the ONNX export of the same weights.

What a block carries depends on what its platform needs to know. Cloud blocks hold the provider’s routing slug, the parameters the model accepts, and a pricing basis (per token, per image, per second of video). Local blocks hold download size, a minimum-RAM floor for gating, and capability flags like vision input or native tool calling. Everything downstream reads this one file: the model picker, the settings panel, the cost estimates, and the dispatch itself.

That is the payoff: routing stops being a decision tree and becomes a lookup. Local runtimes get fixed sentinel ids; cloud providers are stored credentials with UUIDs. The request handler checks for a sentinel, otherwise resolves the credential and switches on its kind. Four executors per modality, each owning one backend’s weirdness. The router itself stays boring, because every model-specific decision was already made by the catalog before the request reached it.

One entry per model, wherever it runs: the catalog answers “where can this run?”, not “what does this provider sell?”. Illustration generated with Seedream 4.5 via Runware.

The catalog, not the UI, is the parameter contract

Models disagree about parameters even more than platforms disagree about transports. Across CSuite’s catalog, the model entries declare 559 input parameters between them, and the settings panel is rendered from those declarations: a model with no temperature parameter simply shows no temperature slider. The same schema does the reverse job at the boundary. Before a request leaves the app, everything the target model didn’t declare is dropped, so the UI cannot send what the model cannot accept.

Backend
“Temperature” lives at
“Max tokens” lives at
Replicate
input.temperature
input.max_tokens
Runware
settings.temperature
settings.maxTokens
Ollama
options.temperature
options.num_predict
transformers.js
temperature
max_new_tokens
One user-facing knob, four request shapes. The user sees a single temperature slider; the catalog decides what each backend is actually sent, and anything a model didn’t declare is dropped before the request leaves the app.

Declaring the parameter is half the contract; placing it is the other half, and that lesson cost us a production break. Runware’s text API nests sampling options inside a settings object and rejects the whole request when one arrives at the top level. Our app once sent maxTokens top-level, and every Runware text generation for models declaring that parameter failed until the mapping layer learned to fold catalog names into Runware’s envelope. Text-to-speech was stricter still: one model’s speed belongs inside a speech object, its turbo flag inside settings, and a misplaced parameter that carried a default value failed every single generation, not just the ones where a user touched the control.

Two lessons survived. First, strict APIs are a gift: an error that names the offending key is the best failure mode an integration can have, far better than a lenient API silently ignoring what you send. Second, capability is per model, not per platform. One flagship model rejects the temperature parameter its siblings accept; reasoning-effort enums differ between models of the same family. The catalog carries a per-model enum for each of those rather than assuming a family resemblance, because the API told us otherwise.

Stream the whole string, not the diff

Streaming text is where the transports diverge most. Replicate streams through its SDK. Runware’s socket returns whole task results. Ollama emits newline-delimited JSON objects, each carrying a small delta of the reply. The app’s interface process needs one contract for all of them, and CSuite’s is deliberately redundant: every chunk pushed to the UI carries the full string so far, and the renderer replaces what it has rather than appending.

Deltas are the textbook answer because they minimize bytes. But at chat scale the bytes are trivial: even resending the whole string on every chunk, a long reply moves a few megabytes between two processes on the same machine. Deltas buy their efficiency with fragility: they assume nothing is ever dropped, duplicated, or reordered between producer and screen. Cumulative chunks are self-healing. A lost message costs one skipped repaint, and the next message repairs the screen completely. Runware’s own protocol gestures at the same reality from the other side: its API buffers undelivered results for 120 seconds so a client that reconnects can collect what it missed. When the payload is small, design for recovery, not for byte savings.

Delta chunks
1. “The
2. “ catalog
3. “ is ← dropped
4. “ the
5. “ contract.
screen: “The catalog the contract.”
Cumulative chunks
1. “The
2. “The catalog
3. “The catalog is ← dropped
4. “The catalog is the
5. “The catalog is the contract.
screen: “The catalog is the contract.”
The same lost chunk, two outcomes. With deltas, a dropped message corrupts the text until the end of the stream. With cumulative chunks, the next message repairs it, because every message carries the full string so far.
Every transport loses a packet eventually; the contract decides whether the reader notices. Illustration generated with Seedream 4.5 via Runware.

Cancel means four different things

The Cancel button is one control in the interface and four different operations underneath. CSuite keeps a single abort registry: the interface mints a request id for every generation, and cancel aborts by id. What that abort does depends entirely on the backend it reaches.

ReplicateAbort the HTTP request
The SDK honors an AbortSignal; a prediction canceled mid-run still bills for the compute it used.
RunwareDisconnect the WebSocket
There is no per-task cancel in flight; dropping the socket abandons the tasks riding on it.
Ollama (pulls)Abort the download
Model pulls are streamed and abortable per request; partial layers survive for the retry.
Local text generationNot cancellable today
The honest row. Stopping an in-process token loop cleanly is real work, and it isn't done yet.
What the one Cancel button actually does, per backend. The interface sees a single registry keyed by request id; each executor maps the abort onto whatever its transport can honor.

The last row is the point of the table. A unified interface makes it tempting to promise unified behavior, and sometimes the truthful answer is that one backend cannot honor the promise yet. Shipping the button that works on three backends, and being honest about the fourth, beats either hiding the button everywhere or faking a cancel that leaves a model spinning CPU in the background.

API keys never meet the interface

A desktop app has a trust boundary that web apps don’t make you think about. In Electron’s process model, the main process has full system access while the sandboxed renderer, the part that draws the interface and runs the most third-party code, does not. CSuite treats that line as the security architecture: every provider call, credential read, and file write happens on the privileged side, and the interface asks for work over a validated message channel instead of doing it.

Keys are the clearest example. They are stored only on the privileged side, encrypted at rest with Electron’s safeStorage, which delegates to the OS keyring: Keychain on macOS, DPAPI on Windows, Secret Service on Linux. The interface never receives a key at all; it renders a last-four-characters hint. Decryption is deferred and per-provider, so the macOS Keychain prompt fires when a stored provider is first used, not as a wall of prompts at launch. The same discipline applies to disk: every path the interface asks to write is resolved and checked against the project folder, so a compromised or simply buggy renderer cannot name a file outside it. If you bring your own keys to AI tools, this is the shape to look for; we’ve written before about why BYOK changes the deal.

The interface can see that a key exists, never the key itself. Illustration generated with Seedream 4.5 via Runware.

What the abstraction costs, honestly

None of this is free, and the costs deserve the same candor as the design. The catalog is a maintenance commitment: every new model means researching its real parameter surface, and published schemas are not the whole contract, so we probe live APIs before trusting them. The lowest-common-denominator temptation is constant; the honest fix is per-model capability flags rather than flattening every backend to the features all four share. And the local half carries weight the cloud half never does: a pinned runtime downloaded at first setup, version markers that force clean re-downloads, and models measured in gigabytes where a cloud model is just a name in a request.

Some asymmetries refuse to hide, and the right move is to stop hiding them. Local text generation can’t be cancelled. One cloud platform can drive tool-calling chat and the other can’t, so the chat picker only offers models that can actually do the job. An abstraction earns trust by being honest about its edges, not by sanding them off.

If you’re building the same shape, the checklist is short. Describe models as data, and make every surface read the same file. Route by lookup: sentinels for local runtimes, credentials for cloud. Filter parameters at the boundary so the UI can’t lie to a model. Stream cumulative state when payloads are small, and keep one abort registry even though every backend cancels differently. Keep secrets in the privileged process, always. The reward for the discipline is that the desktop app thesis actually works: the same button serves a frontier cloud model today and a small local model tomorrow, and the user never has to care which machine answered.

More reading

One-time payment. Yours forever.

No subscriptions. No seats. No renewals. Buy CSuite once, future updates included.

Secure checkout via Stripe. Already have a license? Download the app