PraisonAI Mobile
Run PraisonAI agents natively on iOS and Android with real navigation and a retained chat screen
PraisonAI Mobile runs the agent loop on the phone, dispatches routes to real screens, and keeps your conversation exactly where you left it. One bundle ships to three targets — iOS (Tauri), Android (Tauri), and the Web (an installable PWA) — with the native shell providing the on-device platform integration a browser cannot.
Run PraisonAI agents natively on iOS and Android — no server, no Python, no subprocess — or open the same app as a PWA in any browser tab.
import { createRemoteHttpEngine } from "praisonai-mobile/engines/remote-http";
const engine = createRemoteHttpEngine({ baseUrl: "http://127.0.0.1:8765", http });
for await (const event of engine.run({ prompt: "Plan my week", chatId: "c1", runId: "r1", tools: true, regenerateOf: null, attachments: [] }, signal)) {
if (event.type === "delta") console.log(event.text);
}praisonai-mobile is a Tauri 2 shell around a webview that runs the agent loop in-process. The whole conversation happens on the device.
Quick Start
- Clone and install
bash git clone https://github.com/MervinPraison/PraisonAI cd PraisonAI/src/praisonai-mobile npm install - Open a conversation
Tap a chat and the app dispatches the
chatroute to a live screen — the transcript streams the agent’s reply token by token. - Move around, then come back
Open Settings, scroll, tap About, then return. The chat screen is retained, so you land back where you were — same scroll position, same in-flight streaming.
- Verify everything passes
bash npm run checkcheckrunstypecheck,boundaries, andtest— the three gates that keep the two seams honest.
Navigation
The app dispatches four screens. Only the chat screen survives navigation; the rest rebuild fresh on return. The top bar carries a Chats button next to New chat and Settings.
| Screen | Route | Retained | Why |
|---|---|---|---|
chats | Chat list | No | Rebuilds from a fresh snapshot each visit |
chat | A conversation | Yes | Holds scroll position and live streaming |
settings | Settings | No | Re-reads current values on return |
about | About | No | Static; nothing to preserve |
Destroying the other screens on exit is deliberate: they re-read fresh state on return instead of showing stale data. The chats screen fetches a fresh snapshot each visit — session.list() plus repository.listUnreadable() — so a chat created since the list was last seen appears, and one deleted is gone. See History & Reopen.
New chat
New chat is a clean break: it stops any live run, mints a fresh chat_id, and resets session and transcript state.
| Step | What happens | Why |
|---|---|---|
| Stop the run | controller.stop() | Without this the previous turn kept streaming; its next token re-inserted the old rows into the “fresh” chat. |
| Mint a new id | controller.setChat(mintChatId()) | setChat was never called before, so every request carried chat_id: "unassigned" and an engine keyed one thread for every conversation. |
| Reset state | session.reset(), clear render and transcript | Starts the new conversation from empty. |
| Reset the turn | setChat also sets turn = initialTurn | Without this, a pending approval from the previous chat reappeared on the next Send with live buttons. |
| Clear the live regions | polite.textContent = "", assertive.textContent = "" | Resetting announcer decides what to say next; it does not empty the regions themselves. Without this, the previous conversation’s answer stayed in the accessibility tree of what the user believes is an empty chat — not announced again, but still there for anyone navigating the page. |
// main.ts — the "new-chat" intent, in order.
void app.controller.stop(); // stop FIRST
app.controller.setChat(mintChatId()); // own chat_id, and resets turn
app.session.reset();
transcript.textContent = ""; // safe: not a live region
polite.textContent = ""; // clear the live regions too
assertive.textContent = ""; // — resetting `announcer` clears
// what to SAY next, not what
// the regions still HOLD.Why It Matters
Every selling point is one line.
| Feature | What you get |
|---|---|
| On-device runtime | The agent loop runs inside the phone webview. |
| Offline-capable shell | The UI shell persists chats and settings locally. |
| Swappable engine | praisonai-ts (in-process) or remote-http, same interface. |
| Swappable UI shell | Tauri today, React Native later — one directory swap. |
| 11-event streaming protocol | Every token, tool call, and result is a typed event. |
| Approvals & cancellation | Human-in-the-loop and Stop are built into the run loop. |
How It Works
The app is six layers with two enforced seams: one for the agent framework, one for the UI shell.
A streaming answer is one text row. Every delta event advances the render state in place; the row’s textContent grows, but the DOM node is the same one from the start event to the end event.
Tool cards survive text on either side
A tool row is its own block; the text before and after it are two more. When the answer keeps talking after a tool result, the transcript’s block list is ["text", "tool", "text"] — never ["text", "text"] with the tool row overwritten. Appending to the trailing text block extends only that block; it must not touch the block before it.
Composer
The composer button toggles between Send and Stop, refuses empty input, and keeps your draft as data — so it survives a trip to Settings and autosizes as you type. Full detail on Composer Behavior.
| Behaviour | How it works | Why |
|---|---|---|
| Send / Stop | The button carries data-action="send" and reads Send when idle; while a run streams, the dataset flips to data-action="stop" and the label flips to Stop in the same pass. | Label and behaviour must never disagree — a button that reads Send and stops a run, or reads Stop while sending, is a bug no dataset-only assertion catches. Tapping Stop calls controller.stop() — the run ends and no further tokens are billed. |
| Empty and whitespace input | The composer trims and blocks a submit whose remaining text is "". Send is disabled when the trimmed draft is ""; Stop is always tappable while streaming — the disable is guarded on the action (streaming ? false : draftOf(state).trim() === "") so Stop never goes dead just because the draft happens to be empty. | Whitespace-only prompts never reach the engine, and a streaming run can always be stopped. |
| Draft persistence | The draft lives in ComposerState, keyed by conversation — not in the <textarea>. Navigating to Settings and back restores what you typed. | A route change unmounts the field but not the value; iOS also kills suspended apps without warning. |
| Autosize | The height is heightFor(lineCountOf(text)) on every draft change, clamped between COMPOSER_MIN_PX (52) and COMPOSER_MAX_PX (160). | The floor keeps a hit target; the ceiling stops the composer eating the transcript. A mid-rotation NaN falls back to the floor. |
| Enter under enter-sends | On a tablet with a hardware keyboard, the composer picks enter-sends and a bare Enter submits. Shift+Enter and Alt+Enter both insert a newline instead. | Alt is the newline muscle memory on some keyboard layouts; if only Shift+Enter escaped enter-sends, Alt+Enter would SEND a half-written message — the exact behaviour #4589 fixed. |
Best Practices
Your conversation is preserved
The chat screen is the only screen kept in the DOM when you navigate away. Its nodes stay, so scroll position and any streaming reply survive a trip to Settings and back.
No blank frame during navigation
The next screen mounts before the current one hides, so navigation never flashes an empty page.
Conversations are saved on this device
When the on-device engine answers, the completed turn is written to the same session the chat list reads — so a conversation you had is there the next time you open the app.
Pick the engine that reports what your UI renders
The in-process praisonai-ts engine executes tools but does not announce them, so capabilities.tools is false. Use remote-http when you need tool rows, approvals, or reasoning in the UI.
Treat every stream event as typed
Read events through the 11-event protocol rather than parsing prose. A tool call that silently failed still looks like a normal answer if you infer from text.
Never derive message indices client-side
end.userIndex comes from the engine. A cancelled or errored turn is never persisted, so any index you compute from screen position drifts.
Assert the composer label, not just its dataset
data-action and textContent are set from the same render tick — both keyed on view.turn.phase === "streaming". Assert the label in tests, not just the dataset: a transposition of the ternary that assigns the label survives every test that only reads data-action, leaving a button that reads Send while it stops the run.
Related
How routes become screens, and where the session join lives.
EnginesThe in-process engine, and how it persists a turn.
Native ShellThe Tauri shell — safe-area, keyboard, lifecycle, and back-gesture arbitration.
Web App (PWA)The third target — installable, offline-capable, deployed to GitHub Pages.
Getting StartedClone, run in the webview, and swap engines.
History & ReopenThe chats list and reopening a stored conversation.
Composer BehaviorDraft persistence, autosize, and the Enter policy.
Follow & JumpStick-to-bottom and jump-to-latest.
Route FocusWhere focus lands on every route change.