Project: Baize — a team-facing AI assistant runtime (Go 1.25+, MIT)
Repo: https://github.com/rebornace/baize
This is the follow-up to From a Single Agent Loop to an Auditable Runtime: Baize's Architecture. That post covered the server-side runtime; this one focuses on the two mobile paths.
Baize is a single-process Agent runtime written in Go that sits alongside your business systems, consolidating the LLM, tools, legacy HTTP APIs, and human checkpoints into auditable runs. Once the server side was stable, mobile was not built as a simple mobile web page. Instead, Baize offers two paths: one uses a Capacitor shell to remotely control the full console, so you can approve work on the go with zero development; the other compiles the Go orchestration core into an Android AAR and an iOS XCFramework via gomobile, so the conversation runs on the device and the UI is drawn entirely by your own app. This article walks through the actual code: how the two paths divide the work, the key and approval design of the on-device SDK, and how to choose.
1. There are actually two kinds of mobile demand
Before building anything for mobile, it helps to separate the requirements, because they point at two different problems.
The first kind is internal teams and operators handling things on the go. They need to read conversations, do human approvals, change settings, and deal with logins from their phone — essentially "carry the desktop console in my pocket." In this scenario the conversation engine, connectors, and approval flow are already running stably on the server; the phone only needs an entry point, not a second implementation.
The second kind is embedding Agent capabilities into your own product. These teams want a branded, native UI, with the conversation and tool loop running directly on the device and less dependence on an always-on sidecar, and they have their own requirements for UI and key management. Here the phone is not a remote control — it is where the capability lives.
The two demands differ in who owns the UI, where the code runs, and the integration cost. Forcing them into a single path would leave embedders stuck with a generic page, while operators who just wanted mobile approvals would be forced to build a native UI. Baize therefore splits them into two paths, each handling one side.
2. One diagram: the two paths
Path A is a remote-control console: the phone shell does not run a conversation engine, it loads the full remote console from the same origin. Path B is the on-device SDK: the Go orchestration core is compiled into the app, the UI is drawn by the app, and the conversation runs on the device. Let's expand each in turn.
3. Path A: a thin shell, the full console with zero development
Path A is deliberately light. You can open the reachable /ui directly in a mobile browser, or use the Capacitor project in the repository to wrap a WebView and build an Android or iOS app.
The shell loads the remote console from the same origin: once you configure the server address, the WebView simply opens the remote /ui. Conversation, settings, human approvals, connectors, MCP, messaging channels, and browser login are all identical to the desktop — nothing is lost by moving to the phone. The only things the shell handles itself are mobile nuisances: the safe area, the keyboard, and opening login pages in the system browser.
There is one deliberate choice here: the shell loads the remote /ui from the same origin by default rather than proxying another layer of HTTP inside the shell. Conversation and approval depend on server-pushed events (SSE); adding an HTTP proxy inside the shell would buffer those streaming responses and break the experience of messages arriving one by one. Loading from the same origin keeps streaming replies and approvals smooth inside the shell.
From an architecture standpoint, the shell runs no conversation engine at all — it is, quite literally, a remote control. The integration cost is close to zero: without writing any mobile code, the team gets the same operational capabilities on a phone as on desktop. The only deployment detail is that the public address must be reachable from the phone — the phone talks to the server address, not the localhost loopback on your computer.
4. Path B: compile the Go orchestration core into the app
Path B answers a different need: embedding Agent capabilities into your own product, with a native, branded UI.
Baize compiles the Go orchestration core into an Android AAR and an iOS XCFramework via gomobile, and exposes a binding-friendly public API (the binding package is mobileagent). There is a real engineering detail here: gomobile cannot bind Go internal packages, so Baize re-exports the binding-friendly interfaces from a separate top-level package, while the internal implementation stays under internal. The binding layer only surfaces the handful of methods an app actually needs.
What runs on the device is a slimmed-down, OpenAI-compatible conversation and tool loop. The division of work between the app and the SDK is clear:
The app owns layout, theming, the history list, the input box, and the approval card; it owns secure storage of keys and the remote-config fetch strategy; and it owns thread management (network calls must not block the main thread).
mobileagent owns the session, the conversation, the tool loop, and the pause/resume of approval. Every result is returned as a JSON string, and it never touches any UI.
The call surface is intentionally small:
Configuresets the model endpoint, key, and model name once.Sendruns one conversation turn and stops when it hits a tool that needs approval.ApproveandDenyhandle the two approval decisions.ChatOncedoes a single-turn connectivity test without tools.Resetclears the in-memory history.NewAgentcreates an independent session.MessageCountreturns the number of messages.HasAPIKeyreports whether a key is already held in memory.
5. Why the on-device SDK is slimmed down
The on-device SDK deliberately does not move the full server-side runtime into the phone. On the device it keeps only an in-memory session and a minimal tool surface: today there are two tools, get_time and http_get. get_time executes directly and returns the time, while http_get must pass human approval first. The full connector set, MCP bridging, messaging channels, and the operational approval product surface stay on the server Runtime.
This is a division of labor, not a capability cut. The server Runtime is aimed at "connect many systems and keep operating them as a team," which requires the full connector, audit, and operations surface. The on-device SDK is aimed at "close, light, embedded in your own product," which needs a controllable conversation loop and a minimal tool set. Keeping the two separate makes the on-device component small and controllable and leaves teams free to draw their own experience on top; when the full capability is needed, the app can always fall back to the server Runtime.
The tool loop itself has a boundary: a single turn's tool loop is capped at a fixed number of rounds (currently 8). When it reaches the cap, it ends and returns, so the model cannot loop forever on the device. The session and keys live only in memory and are never persisted in the Go binding layer — they are cleared on reset or exit.
6. Keys go to the system safe, not the Go package
There is one explicit rule in the on-device design: the Go binding layer only holds the key in memory and never writes it to disk. Anything that needs to persist is handed to the native safe provided by the app's platform.
On Android, the sample uses EncryptedSharedPreferences. The master key uses the AES256-GCM scheme, with keys encrypted with AES256-SIV and values with AES256-GCM. What gets stored includes the model endpoint, key, model name, and config endpoint, and there is a separate method to clear the key. On iOS, the equivalent is the Keychain, stored by service name and account name, and restricted to this device only and available after first unlock.
The flow is: when the app starts (or needs the key), it reads the key from the system safe, then injects it into the on-device runtime's memory via Configure; the Go binding layer never handles persistence. The reason is that a cross-platform Go binding cannot guarantee the security level of key storage, whereas EncryptedSharedPreferences and the Keychain are the security boundaries the platform actually endorses — hardware-backed or managed by the system as a whole. The binding does not overstep into storage it cannot do well; it hands security to the platform, and the app fully owns the key lifecycle.
7. Foreground human approval: a JSON status protocol
Human-in-the-loop on the device is expressed through a simple JSON status protocol. The return values of Send, Approve, and Deny are all JSON strings that the app parses to decide what to render:
done: the turn is finished; the UI shows the reply in thetextfield.needs_approval: a tool is waiting for approval, carryingcall_id,name,args_json, andsummary. The app renders an approval card from these; approving callsApprovewith thecall_id, and rejecting callsDeny. Until the user makes a choice, no newSendshould be issued.error: execution failed; the UI showsmessage.
The approval summary is a single human-readable sentence. For http_get, it produces the method ("GET") plus a truncated URL, so on a phone you can see at a glance what the app is about to call.
The http_get that requires approval is a read-only tool, but even though it is read-only, Baize still requires explicit approval and adds a set of guardrails when it runs: only http and https are allowed; requests have a timeout (currently 15 seconds); redirects are capped (currently 3); the response body has a size limit (currently 256 KB, and anything larger is truncated and marked); and the result carries the final address (final_url) so you can verify where a redirect actually landed. Rejecting fires no network request at all and simply lets the conversation continue.
Narrowing what an on-device model is allowed to do is the core idea: keep tools read-only, require explicit approval for actions, and keep the network boundary clear — then on-device autonomy is contained within a safe range.
8. Remote config, multiple sessions, and desktop debugging
So the app does not have to hardcode the model endpoint and model name, the binding layer offers remote config: FetchRemoteConfig pulls JSON from a config endpoint you own, and ApplyRemoteConfigJSON merges that config into the in-memory session. The config can include the model endpoint and model name, and optionally the key. The fetch strategy is decided by the app; the SDK only applies it.
For multi-session scenarios, use NewAgent to obtain an independent instance rather than sharing the package-level default session. Each instance maintains its own history and pending-approval state.
You do not have to tether a phone during development. Baize ships a desktop debug entry point that runs the binding layer directly on your computer, and it includes a fake model so you can get the UI and approval cards working before swapping in a real model. When an Android emulator needs to reach the host machine, use the emulator's standard host address (10.0.2.2).
9. How to choose
The decision is straightforward:
If you want the full settings pages, connectors, and operational approval flow, and the phone is only a portable entry point, choose Path A.
If you want a branded native UI, the conversation running on the device, and less dependence on an always-on sidecar, choose Path B and draw your own UI using the empty shell in the repository as the calling sample.
The two can coexist: operators and internal teams use Path A, while the end-user-facing app uses Path B.
Closing
The two mobile paths are, at heart, separating two different problems: "the operator's portable console" and "the embedded on-device Agent." The former pursues zero development and full parity with the desktop; the latter pursues a native experience, on-device control, and a clear key boundary. The key decisions in the on-device SDK — holding keys only in memory, requiring approval even for read-only tools, giving network actions explicit guardrails, and keeping the session and tool loop minimal — all revolve around one goal: letting the capability run safely, and personally, on the device.
Baize is an open-source project under continuous development (written in Go, MIT licensed). The mobile Capacitor shell, the Android and iOS empty shells, the on-device bindings, and the corresponding build scripts are all in the repository.
Repository: https://github.com/rebornace/baize
Docs: https://rebornace.github.io/baize
If you are also putting Agents into production — especially if you are thinking about how to safely bring capabilities onto a phone — I'd like to hear how you handle mobile and key management. The full mobile getting-started guide and build steps will be covered in a follow-up post.

Top comments (6)
Since the session and pending-approval state live only in memory on Path B, I’d test killing the app while a
needs_approvalcard is open. After relaunch the oldcall_idno longer exists, so the app has to treat a restored card as dead rather than callApprovewith it. DoesApprovewith an unknowncall_idreturn a clear error, and is the lost turn shown to the user or do they just see an empty conversation?Great catch — that’s the kill/relaunch edge Path B still owns too quietly.
Today history and pending live only in Go memory, so an old call_id after relaunch is dead (no pending approval / call_id mismatch). What we’re aligning on: persist the chat for display, treat restored approval UI as stale, and let the user continue/retry for a new approval card — resume the task without reviving executable pending.
We’ll track that in the repo; if you watch github.com/rebornace/baize you’ll see the commits/docs as they land, and I’ll leave a follow-up comment here once it’s in.
From the Auten team (we build a tool that lets coding agents drive a real screen, phone included, beta), so key handling on devices is close to home for us. For Path B, where does the model API key end up: baked into the app's keystore, or does the on-device core fetch a short-lived token from your server per session? One thing that worked for us: logins never pass through the model at all, the tool types the saved value locally and the agent only sees that it was filled.
Hi [Name],
Thanks for following up again — good to hear from you. I’ve appreciated your earlier comments on device-side key handling, and your point here about model-key placement plus “logins never pass through the model” is exactly the split we care about.
Path B today: vault owns the credential (EncryptedSharedPreferences / Keychain) → Configure into Go memory only. For SMEs who want less always-on sidecar cost, we’re framing three tiers: revocable on-device key (default), optional thin chat/completions gateway (phone still orchestrates), and heavy login/connectors on Path A / server — business logins stay out of the model.
We’ll publish the topology/docs in the repo as we tighten this. Watching github.com/rebornace/baize is the best signal; I’ll reply here again when the follow-up is up.
Thanks again for staying engaged — really appreciate your thoughtful follow-ups.
Exporting a thin facade package because gomobile chokes on internal packages is one of those unglamorous friction points that rarely makes it into high-level architecture posts. The boundary on local tools makes sense on mobile. Once an on-device runtime fires unconstrained background requests without native OS confirmation, background battery draw and silent quota exhaustion become immediate operational headaches.
Appreciate this — the mobileagent facade and the tight on-device tool boundary are deliberate for exactly the battery/quota reasons you named.
Core Path B stays the conversation loop + HITL; richer/riskier surfaces stay opt-in with limits and clear guidance rather than quiet defaults.
Further hardening will show up on github.com/rebornace/baize — feel free to watch the repo, and I’ll comment back here after the next mobile pass.