Skip to main content
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.
The same dist/ serves all three: the Tauri shell on iOS and Android, and a browser as a Progressive Web App. The manifest, icons, and service worker are inert inside the shell, so one build covers every target. See Web App (PWA).
praisonai-mobile is a Tauri 2 shell around a webview that runs the agent loop in-process. The whole conversation happens on the device.
Assistant messages and tool results render as plain text by construction — every row’s content is set through textContent, never innerHTML. Model output, tool results, and summarised web pages cannot inject markup into the transcript. An untrusted string can appear on screen; it cannot become a script.

Quick Start

1

Clone and 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

check runs typecheck, boundaries, and test — the three gates that keep the two seams honest.
The app ships on iOS 16+ and Android API 26+ via Tauri. The native shell handles safe-area insets, keyboard height, lifecycle, and the back gesture — see Native Shell.

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.
When you scroll up in a conversation, open Settings, and come back, you land where you left off — the transcript keeps its scroll position and any in-flight streaming. main.ts registers the retained chat screen with screens.nodes.set("chat", screen), so transition treats it as live and never rebuilds it.
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.
The stop comes before the clear. Clearing without stopping left the previous run streaming into an empty render, which re-inserted the old conversation’s rows — and queued prompts ran into it too.
The transcript is a plain element; the live regions are what a screen reader inspects. Emptying one is not emptying the other. New chat has to clear both.
The launch conversation also gets a real chat_id. createApp now passes chatId: deps.newChatId() into createRunController, so the very first conversation of every launch — the one the user never explicitly created via New chat — no longer goes to the engine as the literal "unassigned". Against an engine keying history by chat_id, that string once made every user’s first chat on every device one shared thread. See Architecture — Boot Order.

Why It Matters

Every selling point is one line.

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.
The tool row is not merged into an adjacent text block — its output belongs to a specific tool_call and lives on its own row. Rendering that treats the transcript as one growing string cannot represent a tool that ran mid-answer.
The transcript carries a jump-to-latest button, hidden by default and shown only when you have scrolled off the bottom of a streaming transcript. New tokens never yank you back down while you are reading; a tap on the button follows the stream again. See Follow & Jump.

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.
Alt+Enter and Shift+Enter are equivalent under enter-sends. On-screen keyboards without an Alt key are unaffected — they never send with Alt. Pinned by "Alt+Enter inserts a newline under enter-sends, like Shift+Enter" and "a bare Enter still sends under enter-sends" in composer.test.ts.

Best Practices

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.
The next screen mounts before the current one hides, so navigation never flashes an empty page.
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.
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.
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.
end.userIndex comes from the engine. A cancelled or errored turn is never persisted, so any index you compute from screen position drifts.
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.

Architecture

How routes become screens, and where the session join lives.

Engines

The in-process engine, and how it persists a turn.

Native Shell

The Tauri shell — safe-area, keyboard, lifecycle, and back-gesture arbitration.

Web App (PWA)

The third target — installable, offline-capable, deployed to GitHub Pages.

Getting Started

Clone, run in the webview, and swap engines.

History & Reopen

The chats list and reopening a stored conversation.

Composer Behavior

Draft persistence, autosize, and the Enter policy.

Follow & Jump

Stick-to-bottom and jump-to-latest.

Route Focus

Where focus lands on every route change.