Skip to content

feat(ai): run the stack's AI on another machine or a hosted provider - #110

Merged
arthware-dev merged 14 commits into
mainfrom
feat/external-ai-provider
Sep 22, 2026
Merged

arthware-dev merged 14 commits into
mainfrom
feat/external-ai-provider

Conversation

@arthware-dev

Copy link
Copy Markdown
Contributor

Summary

  • The stack's AI can run on another machine or a hosted provider without installing anything locally: ./stack ai connect <url>, and local to switch back. The agent no longer requires the local ai stacklet.
  • Voice messages go to a dedicated speech server when one is set (--whisper <url>), otherwise to the AI server if it lists a speech model. stack list shows the AI as remote, connect warns when an address is outside the home network, and stack up ai asks before replacing a remote setup.
  • oMLX and Whisper now follow the port/domain bind rule, so another Mac can use this one's engine. Also: stack up ai restarts a stopped oMLX, a declined hook question is a cancel rather than a failure, and the --full installer is removed.

Commits are coherent on their own; rebase merge.

When a hook or command saved a setting (`ctx.cfg(key, value)`), the
writer replaced every quoted `key = "..."` in the file. Sections were
ignored and comments were not skipped, so saving `[ai] default` also
rewrote the commented-out model alternatives and any `default` key in
another section. A key whose value was not a quoted string was appended
again instead of replaced, which left a file TOML refuses to parse.

The writer now changes the one assignment inside the named section,
keeps any trailing comment on that line, and escapes the value.
The agent refused to start unless the local AI stacklet was set up,
although it only ever talks to `[ai] openai_url`. A household running
its models on another machine on the network, or with a hosted
OpenAI-compatible provider, could not use the agent at all.

The agent now requires only `messages`. Before it starts it checks the
AI server instead:

- no `[ai] openai_url` stops the start and names both ways to get one,
  `./stack up ai` or `./stack ai connect <url>`
- a server that does not answer is a warning; the agent starts and
  replies once the server is back
- a configured model the server does not list is named in a warning
Voice messages always went to `[ai] whisper_url`, which defaulted to
the local Whisper on port 42062. On an instance without the ai
stacklet that address serves nothing, and no key was ever passed on,
so a hosted provider refused every voice message.

Transcription is the OpenAI `/audio/transcriptions` call, which the
hosted providers answer too, and oMLX with a speech model loaded. So:

- with `[ai] whisper_url` unset, voice messages go to `[ai] openai_url`
  with `[ai] openai_key`: one server, one key
- with it set, they go to that dedicated speech server with
  `[ai] whisper_key`, never the AI key
- `[ai] whisper_model` names the speech model to ask for, `whisper-1`
  by default; other servers name theirs and refuse any other
- `stack up ai` sets the local Whisper as the speech server when it
  builds it, and the installer no longer writes one for everyone

Core, memory and chatai now receive the key and the model. Existing
instances keep the `whisper_url` their installer wrote and see no
change.
`./stack ai connect <url>` points the stack at an OpenAI-compatible
server on another machine on the network, or a hosted provider,
without installing anything on this Mac. `./stack ai connect local`
switches back to the engine `./stack up ai` installed. Before, the only
way was the "external endpoint" answer inside `./stack up ai`, which
still installed Whisper and TTS locally and was labelled unsupported.

The command checks the server answers, keeps the configured model if
the server has it, takes the only chat model it lists otherwise, and
asks for `--model <id>` when there are several. Nothing is written
when the server does not answer or wants a key it was not given
(`--key`).

Voice messages:

- `--whisper <url>` sets a dedicated speech server, which then gets
  voice messages whatever the AI server offers; the command checks it
  answers the transcription call
- without one, the AI server gets them, and can only transcribe with a
  speech-to-text model loaded; the command looks for one in its model
  list, records it as `[ai] whisper_model`, and warns when there is
  none
- `--whisper ai` removes the speech server, and is refused when the AI
  server lists no speech model

A server outside the home network gets a warning naming what is sent
there: document text, notes, chat questions and voice messages.
Loopback, private and Tailscale addresses count as home.

Nothing restarts; the command names the running stacklets to restart.
CLI plugins can now save settings through `config["set_cfg"]`, the
plugin-side twin of a hook's `ctx.cfg(key, value)`.
oMLX runs as a Homebrew service that the first `./stack up ai` starts.
Nothing started it again: once it stopped, by hand, a brew upgrade or
a crash, `./stack up ai` left it stopped, and every AI request failed
until it was started by hand or at the next login.

`./stack up ai` now starts the service when the local engine is the
provider and it does not answer. A remote provider starts nothing on
this Mac.
After `./stack ai connect <url>`, bringing up the ai stacklet means
the AI engine and speech-to-text on this Mac take over from that
server. That is a different setup, not a restart, so `./stack up ai`
now says which endpoint is configured and asks first. No is the
default and leaves the configuration as it was; without a terminal it
stops instead of asking. Yes switches chat and voice to this Mac
together and names the default model, which a hosted setup may have
set to one this Mac does not have.

The first `./stack up ai` no longer offers an external endpoint of its
own. That path recorded a remote server and still installed Whisper
and TTS here. `./stack up ai` is the local engine; a server elsewhere
is `./stack ai connect <url>`, which the question now points to.
`./stack install --full` ran the first installer, which asked for every
stacklet up front. It had drifted: it still offered a `bots` stacklet
that no longer exists, and an "external endpoint" for AI that recorded
a remote server while installing Whisper and TTS locally anyway.

The messages-first installer is the only one now, and takes the name
`installer.py`. It keeps the one thing it borrowed from the old one,
the summary shown when a configuration already exists, which now
points to `./stack ai connect` for another AI server.
After `./stack ai connect <url>`, `./stack list` still showed the ai
stacklet as `online localhost:42060` whenever its local containers ran:
its LLM check probes the configured address, and the remote server
answered it. The row claimed the engine on this Mac was serving when
nothing used it. On an instance that never installed it, the row said
`available`, as if there were no AI at all.

The row now reads `remote` with the server's host, set up locally or
not, and is degraded, naming the address, when that server does not
answer. The JSON output carries `remote` with the address.

A stacklet declares this in its manifest: `remote = "<address>"`, a
template that renders empty when the stacklet serves itself. While it
is remote only the health checks marked `remote = true` run; the others
probe this Mac. The ai stacklet uses the new `{ai_remote_url}`, the AI
address when the provider is external.
Switching to a remote server with `./stack ai connect <url>` left the
local engine running: oMLX, Whisper and the speech container kept their
memory, and `./stack restart` offered to restart the ai stacklet, which
now asks about switching back to local mode.

`./stack ai connect` now says the local engine is no longer used and
that `./stack down ai` stops it. That stop reached oMLX only when
famstack had installed it, so an instance first set up with a remote
endpoint kept oMLX running after `./stack up ai` had started it.
`./stack up ai` now records that famstack manages oMLX whenever the
local engine is the provider.
With several models on the server and none chosen, `./stack ai
connect` asked for `--model <id>` without saying which ids exist. The
list was in the JSON output, but a terminal only shows the error line.
The error now lists them.
When a hook asked the admin something and got a no, `./stack up`
printed "Hook on_start.py error", then "on_start hook failed" twice,
and told the family room the stacklet had failed to start.

A hook now raises `stack.hooks.Cancelled` for that. `./stack up`
prints its message once, exits 1 and tells nobody. The ai stacklet
uses it for a no to setting up the local engine and to replacing a
remote AI server, and when there is no terminal to ask in.
The local Whisper listened on loopback only, whatever the stack's mode.
A second Mac pointed at this one with `./stack ai connect <url>
--whisper <this Mac>:42062` was refused, and so was a reverse proxy
reaching it over the LAN.

It now follows the rule every other service binds by: all interfaces
in port mode, loopback in domain mode. Like the other services in port
mode it answers anyone on the network, without a password. The next
`./stack up ai` rewrites the LaunchAgent and reloads it.
The admin guide gets a section on `./stack ai connect`: pointing the
stack at another machine or a hosted provider, switching back with
`local`, where voice messages go and how `--whisper` changes it, what
a hosted provider receives, and what `./stack up ai` does on a remote
setup. The first-run steps no longer mention the external endpoint
`./stack up ai` used to offer.

`stack.example.toml` explains the speech server and its key and model,
and the operator command map and troubleshooting table cover the new
command, the `remote` state in `./stack list`, and voice messages that
fail on a remote setup.
famstack never set where oMLX listens, so it kept its own default,
loopback, in every mode. Another machine pointed at this Mac with
`./stack ai connect <this Mac>:42060` was refused.

`./stack up ai` now sets it by the rule every other service binds by:
all interfaces in port mode, loopback in domain mode. It checks on
every start and restarts a running oMLX when the address changes,
since oMLX reads its settings at start only. In port mode the engine
answers anyone on the network who has its API key, `[ai] openai_key`.
@arthware-dev
arthware-dev merged commit 4af50bf into main Sep 22, 2026
1 check passed
@arthware-dev
arthware-dev deleted the feat/external-ai-provider branch October 7, 2026 11:51
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant