An Android Jetpack Compose chat client that connects to a TypeAgent agent-server via Microsoft DevTunnel.
- Jetpack Compose chat UI (message history, streaming bubbles, connection status, send button)
- OkHttp WebSocket usage on Android
- TypeAgent agent-server RPC protocol:
joinConversation/submitCommandregisterClientAgentwith an inline action schema- Client-hosted
executeActioncallbacks - Inbound
appendDisplay,setDisplay,setDisplayInfo, and command completion events
- Incremental assistant response streaming into a single bubble per
requestId, honouring the SDK'sDisplayAppendMode(inline,block,temporary,step) andDisplayMessageKindstyling the same way the Electron shell does - Chat history that survives both configuration changes and process death, and resumes the same server-side conversation (see Conversation persistence)
- DevTunnel authentication via
X-Tunnel-Authorizationheader - Build-time configuration via environment variables and
BuildConfig
The chat conversation is owned by a ViewModel, so rotation, theme, font-scale
and locale changes no longer tear down the socket and the transcript.
A ViewModel dies with its process though, which Android does routinely once the
app is backgrounded. The transcript is therefore mirrored to SharedPreferences
by ConversationStore and restored on the next start, capped at the most recent
ConversationSerializer.MAX_PERSISTED_MESSAGES messages. The server cannot fill
this gap for this client: it reads no display history, so the client has to own
its own transcript.
The joined conversationId is stored alongside the messages and passed back into
joinConversation as a connect option on the next launch, so the client resumes
the exact conversation the transcript belongs to rather than landing on the
server's default one. If the server no longer has that conversation it answers
Conversation not found; the join then falls back to the default conversation
once and the orphaned transcript is dropped from both screen and disk. Every
other join failure - transport, tunnel auth - still surfaces as a connection
error, so an outage cannot silently move the user into a different conversation.
Terminology. This is a conversation (user-facing identity and chat history), not a dispatcher session (configuration, caches, agent state). The
SharedPreferencesfile is still namedtypeagent_chat_session.xmlbecause that name is pinned in the backup rules and already exists on devices; renaming it would orphan stored transcripts.
Everything lives in one private SharedPreferences file inside the app's own
sandbox (typeagent_chat_session.xml), readable only by this app. Nothing is
written to shared or external storage.
Two independent limits keep it from growing without end:
| Limit | Constant | Effect |
|---|---|---|
| Size | MAX_PERSISTED_MESSAGES (200) |
Only the newest 200 messages are kept. A full 200-message transcript measures ~59 KB. |
| Age | MAX_MESSAGE_AGE_MILLIS (30 days) |
Messages older than the window are deleted, including while the app is not running. |
Retention runs on both save and load. Because a load only filters what it reads, a read that drops anything immediately rewrites the file, so expired messages are erased rather than merely hidden. Expiry is applied to what is stored, not to what is already on screen: messages already visible stay for the rest of the conversation rather than disappearing mid-chat.
Saving is debounced (ChatViewModel.SAVE_DEBOUNCE_MS). SharedPreferences
rewrites its entire file on every commit and the message list re-emits on every
streaming chunk, so an undebounced save would rewrite the whole blob dozens of
times per reply.
The transcript is excluded from Android's Auto Backup (backup_rules.xml and
data_extraction_rules.xml), so conversations are never uploaded to the user's
cloud account. A direct device-to-device transfer does carry it, since that
copies straight to the new phone without a cloud round trip.
Clear chat in the header removes the transcript from both the screen and disk
after a confirmation. It is a client-side reset only, matching @clear on the
other TypeAgent canvases: the conversation itself is untouched, so the agent
keeps its memory and the next launch resumes the same conversation.
After joining a conversation, the app registers androidDevice as a
client-hosted agent. Its action schema is packaged in the APK and sent inline to
TypeAgent. TypeAgent translates or directly invokes the typed action, then calls
executeAction on the app over the existing WebSocket connection, and the app
reports success or failure back as the action result.
This is the only path for device actions. The legacy fire-and-forget
takeAction path served by the server-side androidMobile agent has been
removed; the clientio: channel is still used, but only for display and user
interaction traffic.
| Schema action | Android intent | Notes |
|---|---|---|
setAlarm |
AlarmClock.ACTION_SET_ALARM |
Scheduled in the background (EXTRA_SKIP_UI = true) and confirmed with a toast. The optional days parameter takes lowercase weekday names and becomes a repeating alarm via EXTRA_DAYS; an unrecognised name fails the whole action rather than setting the alarm on a subset of the days asked for. |
setTimer |
AlarmClock.ACTION_SET_TIMER |
Starts the countdown in the background (EXTRA_SKIP_UI = true) and confirms with a toast, so a chat request never yanks the user out of the conversation. Durations outside the documented 1..86400 second range are rejected rather than clamped. |
showAlarms |
AlarmClock.ACTION_SHOW_ALARMS |
Opens the clock app's alarm list. Takes no parameters, so the dispatcher sends no parameters object and the parser must not require one. |
showTimers |
AlarmClock.ACTION_SHOW_TIMERS |
Opens the clock app's timer list. Added in API 26; on API 24–25 the action reports that the device does not support it instead of throwing. |
searchNearby |
Intent.ACTION_VIEW with a geo:0,0?q= URI |
Opens the device's maps app on a local search. The intent is implicit rather than pinned to Google Maps, so it resolves on any device with a maps app. |
showLocation |
Intent.ACTION_VIEW with a geo:0,0?q= URI |
Shows one named place. 0,0 means "wherever the query resolves to", so no location permission is involved and no device coordinates are read. |
dialPhoneNumber |
Intent.ACTION_DIAL with a tel: URI |
Only pre-fills the dialer — the user still presses call, so no CALL_PHONE permission is needed and a hallucinated number cannot dial itself. Numbers are held to a dialable charset and rejected, never rewritten. |
composeSms |
Intent.ACTION_SENDTO with an smsto: URI and sms_body |
Opens a pre-filled draft — the user still presses send, so no SEND_SMS permission is needed. With no recipient the draft opens with an empty To field; an unusable recipient is rejected rather than silently dropped. |
webSearch |
Intent.ACTION_WEB_SEARCH with SearchManager.QUERY |
The query travels as an extra rather than being spliced into a URL, so it needs no encoding. |
openWebPage |
Intent.ACTION_VIEW with an http/https URI |
The scheme allowlist is the load-bearing check: ACTION_VIEW would otherwise follow market:, file: or any app's own deep-link scheme, turning "open this page" into an arbitrary-app launcher driven by text the model read. URLs containing whitespace are refused rather than repaired into a different host. |
All actions require the app to be in the foreground: Android 10+ silently refuses background activity starts (no exception is thrown), so the app checks its own lifecycle state first and reports a failure rather than a false confirmation.
The clock actions require the com.android.alarm.permission.SET_ALARM permission
(declared in the manifest, install-time only). Every action needs a matching
<queries> entry so resolveActivity works under Android 11+ package
visibility rules.
The registered client agent needs nothing installed on the server side. Use
@action for a deterministic registration test:
@action --parameters {"originalRequest":"timer","durationInSeconds":30} androidDevice setTimer
@action --parameters {"originalRequest":"alarm","time":"12:00"} androidDevice setAlarm
- Android Studio (recent stable version)
- A TypeAgent agent-server exposed via DevTunnel — see
TypeAgent/ts/examples/remoteClient/README.mdfor server setup - DevTunnel CLI
Once your server is running and tunnelled, set these two environment variables before building the app:
| Variable | Required | Description |
|---|---|---|
TYPEAGENT_SERVER_URL |
Yes | DevTunnel WebSocket URL (e.g. wss://abc123xyz-8999.devtunnels.ms) |
TYPEAGENT_TUNNEL_TOKEN |
Yes | DevTunnel access token |
# PowerShell (Windows)
$env:TYPEAGENT_SERVER_URL = "wss://abc123xyz-8999.devtunnels.ms"
$env:TYPEAGENT_TUNNEL_TOKEN = "<your token>"# macOS / Linux
export TYPEAGENT_SERVER_URL="wss://abc123xyz-8999.devtunnels.ms"
export TYPEAGENT_TUNNEL_TOKEN="<your token>"- Open this folder (
android/samples/mobile-2) in Android Studio - Let Gradle sync finish
- Run Build → Rebuild Project to pick up the environment variables
- Run the
appmodule on your device
The app connects automatically on launch. Tap Retry in the status bar if the connection fails.
Rebuild whenever you change
TYPEAGENT_SERVER_URLorTYPEAGENT_TUNNEL_TOKEN— these values are embedded at compile time.
- Token storage:
TYPEAGENT_TUNNEL_TOKENis compiled intoBuildConfig. Do not distribute APKs built with a sensitive or long-lived token. - Token transmission: The token is sent only as an HTTP upgrade header and is never logged.