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.

ts
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);
}
Diagram

PraisonAI Mobile

📱 User

🧭 Route

💬 Chat Screen

🤖 In-Process Engine

💾 Session

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

  1. Clone and install
    bash
    git clone https://github.com/MervinPraison/PraisonAI
    cd PraisonAI/src/praisonai-mobile
    npm install
  2. Open a conversation

    Tap a chat and the app dispatches the chat route to a live screen — the transcript streams the agent’s reply token by token.

  3. 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.

  4. Verify everything passes
    bash
    npm run check

    check runs typecheck, boundaries, and test — 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.

ScreenRouteRetainedWhy
chatsChat listNoRebuilds from a fresh snapshot each visit
chatA conversationYesHolds scroll position and live streaming
settingsSettingsNoRe-reads current values on return
aboutAboutNoStatic; 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.

StepWhat happensWhy
Stop the runcontroller.stop()Without this the previous turn kept streaming; its next token re-inserted the old rows into the “fresh” chat.
Mint a new idcontroller.setChat(mintChatId())setChat was never called before, so every request carried chat_id: "unassigned" and an engine keyed one thread for every conversation.
Reset statesession.reset(), clear render and transcriptStarts the new conversation from empty.
Reset the turnsetChat also sets turn = initialTurnWithout this, a pending approval from the previous chat reappeared on the next Send with live buttons.
Clear the live regionspolite.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.
ts
// 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.

FeatureWhat you get
On-device runtimeThe agent loop runs inside the phone webview.
Offline-capable shellThe UI shell persists chats and settings locally.
Swappable enginepraisonai-ts (in-process) or remote-http, same interface.
Swappable UI shellTauri today, React Native later — one directory swap.
11-event streaming protocolEvery token, tool call, and result is a typed event.
Approvals & cancellationHuman-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.

Diagram

Two Seams

📱 app

🪟 ui

🔌 adapters

🧠 engines

⚙️ core

📡 protocol

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.

Diagram
DOMRenderEngineDOMRenderEnginestart(msg_id)insert row-textdelta(“one “)row.textContent += “one “delta(“two “)row.textContent += “two “end

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.

Diagram
TranscriptEngineTranscriptEnginedelta(“Let me check. “)blocks: [text]tool_call(ls)tool_result(ls, “a.txt”)blocks: [text, tool]delta(“There is “)delta(“one file.”)blocks: [text, tool, text]

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.

BehaviourHow it worksWhy
Send / StopThe 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 inputThe 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 persistenceThe 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.
AutosizeThe 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-sendsOn 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.
Diagram

send

stop

yes

no

👆 tap button

data-action?

trim empty?

🛑 controller.stop

🚫 nothing sent

📤 send to engine


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