My Weekend Project: Building an AI Agent App with Jetpack Compose
Sometimes, the best projects start with curiosity. Last weekend, I found myself wondering: “What if I could build my own AI agent on Android? Something that doesn’t just chat, but actually does things like calculating numbers or checking the weather?”
And that’s how this little adventure began.
๐ฑ The Idea
We’ve all seen AI chatbots — they’re cool, but often limited to just answering questions. An AI agent is a bit different. Imagine chatting with an assistant who can think and act. Ask it to solve math? Done. Ask it for today’s weather? Pulled in real time. Ask it a random question? It’ll answer using AI.
That’s exactly what I wanted to build — an agent that feels helpful, not just talkative.
Building an AI Agent in Jetpack Compose (with Gemini, Room & Custom Tools)
Artificial Intelligence has moved far beyond simple chatbots. Today, we can build AI agents that don’t just chat, but actually do things—like solving math, fetching weather, or even controlling apps. The best part? You don’t need a heavy framework or massive infrastructure to get started. With Jetpack Compose and a little bit of Kotlin, you can bring an AI agent to life on Android.
In this post, I’ll walk you through how I built a fully working AI Agent app using:
- Gemini API for natural conversations,
- Room Database for saving chat history,
- Custom Tools like a Calculator and Weather fetcher
- No Hilt. No unnecessary boilerplate. Just clean, direct code.
๐งฉ What’s an AI Agent?
Think of a chatbot as someone who only answers questions. An AI Agent is like that friend who not only answers but also takes action when needed. For example:
- You ask: “calc 254+100”* → Agent gives you the correct math result.
- You ask: “weather Delhi” → Agent fetches the live temperature.
- You ask a general question → Agent replies using Gemini.
This blending of conversation + actions is what makes it an agent.
⚙️ The Tech Stack
Here’s what powers our app:
- Jetpack Compose → UI built the modern way.
- Room Database → Persist chat history locally.
- Ktor Client → Make network calls (for Weather API).
- Google Gemini API → Whenever the app needs real AI magic, it’s the Gemini API that does the talking.
- Simple Tools → Calculator & Weather fetcher.
๐ ️ Core Features
- Chat with AI (Gemini) – Ask anything, get natural replies.
- Calculator Tool – Solve math directly inside the chat.
- Weather Tool – Fetch real-time weather by city.
- History with Room – All messages are saved locally.
- Smart Agent – Decides when to use tools vs AI.
๐ฅ️ How It Works
- The logic inside the ViewModel is where the magic happens:
- If a message starts with "calc ", it runs the calculator tool.
- If it starts with "weather ", it calls the weather API.
This gives you a smooth, agent-like experience without complex orchestration.
๐ก Example Conversations
- You: calc 12+34/2
- Agent: Result: 29.0
- You: weather Mumbai
- Agent: ๐ค️ Temp in Mumbai: 31°C
You: Who is the current Prime Minister of India?
Agent (Gemini): The Prime Minister of India is Narendra Modi (as of 2025).
๐ฑ Why Build It in Jetpack Compose?
Compose makes UI fun again. Messages can be displayed in a lazy column, styled differently for user and AI, and updated live as the database changes. With Room integration, your chat history persists even if you close the app. No XML. No RecyclerView boilerplate. Just pure declarative magic.
๐ Where to Take This Next
This is just the beginning. You can extend your AI agent with:
- More Tools → news fetcher, calendar reminders, even controlling IoT devices.
- Typing Animations & Avatars → to make conversations feel alive.
- Sessions with Titles → group chats like in popular AI apps.
- Better UI/UX → shimmer loading, timestamps, auto-scroll.
Over time, this little project can grow into something that feels like a personal AI sidekick, ready to help you whenever you need.
Steps Building an AI Agent in Jetpack Compose (12):
1. Create a New Project in the Latest Android Studio
Open Android Studio
- Start Android Studio on your computer.
- If a project is already open, close it by going to File > Close Project. This will take you back to the Welcome screen.
Begin a New Project
- On the Welcome screen, click New Project.
- If you’re already inside another project, you can create one by selecting File > New > New Project... from the top menu.
Pick a Project Template
- A window will appear showing several ready-made templates.
- For a basic starter app, choose Empty Activity.
- If you plan to use Jetpack Compose for building your UI, go with Empty Compose Activity.
- Click Next once you’ve made your choice.
Set Up Your Project Details
- App name: Give your application a name (for example, MyFirstApp).
- Package name: This is automatically generated based on your app name, but you can edit it if needed (e.g., com.example.myfirstapp).
- Save location: Choose the folder where you want Android Studio to store your project files.
- Language: Pick Kotlin (recommended for modern Android apps) or Java.
- Minimum SDK: Select the lowest Android version your app will support. A lower version works on more devices but may not include the newest Android features.
- Build configuration language (optional): By default, new projects use Gradle Kotlin DSL, which is now the preferred option for build scripts.
Finish and Create
After entering the details, click Finish, and Android Studio will generate your new project with the selected settings.
2. Change the Main Activity
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
setContent {
OpenAiAgentTheme {
/* Scaffold(modifier = Modifier.fillMaxSize()) { innerPadding ->
Greeting(
name = "Android",
modifier = Modifier.padding(innerPadding)
)
}*/
val db = DBModule.provideDatabase(applicationContext)
val messageDao = db.messageDao()
val weatherService = NetworkModule.weatherService
val newsService = NetworkModule.newsService
val aiService = NetworkModule.aiService
val apiKeys = ApiKeys(
openWeatherKey = "your api key",
newsApiKey = "your api key",
aiKey = "your api key"
)
val repo = AiChatRepository(
messageDao = messageDao,
aiService = aiService,
weatherService = weatherService,
newsService = newsService,
apiKeys = apiKeys
)
val vm = remember { AiChatViewModel(repo) }
AiAgentScreen(viewModel = vm)
}
}
}
}
3. AiAgentScreen
package com.codingbihar.openaiagent
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.systemBarsPadding
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextField
import androidx.compose.material3.TextFieldDefaults
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.shadow
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
import kotlinx.coroutines.delay
import java.text.DateFormat
import java.util.Date
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun AiAgentScreen(viewModel: AiChatViewModel) {
val messages by viewModel.messages.collectAsState()
val isTyping by viewModel.isTyping.collectAsState()
val listState = rememberLazyListState()
var input by remember { mutableStateOf("") }
// Background gradient
Box(
modifier = Modifier
.fillMaxSize()
.systemBarsPadding()
.imePadding() // ๐ important fix for keyboard overlap
.background(
Brush.verticalGradient(
listOf(Color(0xFF0F2027), Color(0xFF203A43), Color(0xFF2C5364))
)
)
) {
Column(
Modifier
.fillMaxSize()
) {
// Top Bar
TopAppBar(
title = { Text("๐ค AI Agent", color = Color.White) },
colors = TopAppBarDefaults.topAppBarColors(containerColor = Color.Transparent),
actions = {
IconButton(onClick = { viewModel.clearAllChats() }) {
Icon(
Icons.Default.Delete,
contentDescription = "Clear All",
tint = Color.Red
)
}
}
)
// Messages
LazyColumn(
modifier = Modifier.weight(1f),
state = listState,
contentPadding = PaddingValues(12.dp)
) {
items(messages) { msg ->
ModernMessageRow(msg)
}
if (isTyping) {
item { TypingIndicatorModern() }
}
}
// Input area
Row(
Modifier
.fillMaxWidth()
.padding(8.dp)
.background(Color(0xAA000000), shape = RoundedCornerShape(30.dp))
.padding(horizontal = 12.dp, vertical = 6.dp),
verticalAlignment = Alignment.CenterVertically
) {
TextField(
value = input,
onValueChange = { input = it },
modifier = Modifier.weight(1f),
placeholder = { Text("Ask anything...", color = Color.Gray) },
colors = TextFieldDefaults.colors(
focusedContainerColor = Color.Transparent,
unfocusedContainerColor = Color.Transparent,
focusedIndicatorColor = Color.Transparent,
unfocusedIndicatorColor = Color.Transparent,
focusedTextColor = Color.White,
unfocusedTextColor = Color.White
)
)
IconButton(onClick = {
if (input.isNotBlank()) {
viewModel.sendMessage(input.trim())
input = ""
}
}) {
Icon(
Icons.Default.PlayArrow,
contentDescription = "Send",
tint = Color(0xFF00FFAA)
)
}
}
}
}
// Auto-scroll (with delay for keyboard)
LaunchedEffect(messages.size, isTyping) {
if (messages.isNotEmpty()) {
// small delay to let layout/keyboard adjust
delay(100)
listState.animateScrollToItem(messages.size - 1)
}
}
}
@Composable
fun ModernMessageRow(msg: Message) {
val bubbleColor = if (msg.isUser) Color(0xFF0066FF) else Color(0xFF2E2E2E)
val textColor = if (msg.isUser) Color.White else Color.LightGray
val avatar = if (msg.isUser) "๐ค" else "๐ค"
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = if (msg.isUser) Arrangement.End else Arrangement.Start
) {
if (!msg.isUser) {
Text(avatar, modifier = Modifier.padding(end = 6.dp))
}
Column {
Box(
modifier = Modifier
.shadow(4.dp, RoundedCornerShape(18.dp))
.background(bubbleColor, RoundedCornerShape(18.dp))
.padding(12.dp)
.widthIn(max = 280.dp)
) {
Text(msg.text, color = textColor)
}
Text(
DateFormat.getTimeInstance(DateFormat.SHORT).format(Date(msg.timestamp)),
style = MaterialTheme.typography.labelSmall.copy(color = Color.Gray),
modifier = Modifier.padding(top = 4.dp, start = 8.dp)
)
}
if (msg.isUser) {
Text(avatar, modifier = Modifier.padding(start = 6.dp))
}
}
}
@Composable
fun TypingIndicatorModern() {
val dotCount = remember { mutableStateOf(1) }
LaunchedEffect(Unit) {
while (true) {
dotCount.value = (dotCount.value % 3) + 1
delay(400)
}
}
Row(
modifier = Modifier
.padding(8.dp)
.background(Color(0xFF2E2E2E), RoundedCornerShape(18.dp))
.padding(10.dp)
) {
Text("๐ค " + ".".repeat(dotCount.value), color = Color.LightGray)
}
}
4. AiChatRepository
package com.codingbihar.openaiagent
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
class AiChatRepository(
private val messageDao: MessageDao,
private val aiService: AiService,
private val weatherService: WeatherService,
private val newsService: NewsService,
private val apiKeys: ApiKeys
) {
suspend fun sendUserMessageAndGetReply(sessionId: Long, text: String): String {
messageDao.insert(Message(sessionId = sessionId, isUser = true, text = text))
val trimmed = text.trim()
return when {
trimmed.startsWith("calc:", true) -> {
val expr = trimmed.removePrefix("calc:").trim()
val result = evaluateExpression(expr)
insertAndReturn(sessionId, "Result: $result")
}
trimmed.startsWith("math:", true) -> {
val expr = trimmed.removePrefix("math:").trim()
val result = evaluateExpression(expr)
insertAndReturn(sessionId, "Math result: $result")
}
trimmed.startsWith("weather:", true) -> {
val city = trimmed.removePrefix("weather:").trim()
val resp = withContext(Dispatchers.IO) { weatherService.getWeather(city, apiKeys.openWeatherKey) }
insertAndReturn(sessionId, "${resp.name}: ${resp.main.temp}°C, ${resp.weather.firstOrNull()?.description}")
}
// Inside AiChatRepository
trimmed.startsWith("news:", ignoreCase = true) -> {
val country = trimmed.removePrefix("news:").trim().ifEmpty { "in" }
val newsTop = try {
newsService.getTopHeadlines(country, apiKeys.newsApiKey)
} catch (_: Exception) {
null
}
if (newsTop?.articles.isNullOrEmpty()) {
insertAndReturn(sessionId, "Sorry, news not available right now.")
} else {
val top = newsTop.articles.take(5).joinToString("\n") { "• ${it.title}" }
insertAndReturn(sessionId, "Top news for $country:\n$top")
}
}
else -> {
val aiReq = AiRequest(messages = listOf(MessageRequest("user", text)))
val resp = withContext(Dispatchers.IO) { aiService.sendPrompt("Bearer ${apiKeys.aiKey}", aiReq) }
val aiText = resp.choices?.firstOrNull()?.message?.content?.trim() ?: "No response from AI"
insertAndReturn(sessionId, aiText)
}
}
}
private suspend fun insertAndReturn(sessionId: Long, reply: String): String {
messageDao.insert(Message(sessionId = sessionId, isUser = false, text = reply))
return reply
}
private fun evaluateExpression(expr: String): String {
return try {
val e = net.objecthunter.exp4j.ExpressionBuilder(expr).build()
val result = e.evaluate()
if (result % 1.0 == 0.0) result.toLong().toString() else result.toString()
} catch (t: Throwable) {
"Error: ${t.message}"
}
}
suspend fun clearAllChats() = messageDao.deleteAll()
}
5. AiChatViewModel
package com.codingbihar.openaiagent
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch
class AiChatViewModel(private val repo: AiChatRepository) : ViewModel() {
private val _messages = MutableStateFlow>(emptyList())
val messages: StateFlow> = _messages
private val _isTyping = MutableStateFlow(false)
val isTyping: StateFlow = _isTyping
fun sendMessage(text: String) {
_messages.value = _messages.value + Message(text = text, isUser = true)
viewModelScope.launch {
_isTyping.value = true
try {
val reply = repo.sendUserMessageAndGetReply(1L, text)
_messages.value = _messages.value + Message(text = reply, isUser = false)
} catch (e: Exception) {
_messages.value = _messages.value + Message(text = "Error: ${e.message}", isUser = false)
} finally { _isTyping.value = false }
}
}
fun clearAllChats() {
viewModelScope.launch {
repo.clearAllChats()
_messages.value = emptyList()
}
}
}
6. NetworkModule
package com.codingbihar.openaiagent
import com.squareup.moshi.Moshi
import com.squareup.moshi.kotlin.reflect.KotlinJsonAdapterFactory
import okhttp3.OkHttpClient
import okhttp3.logging.HttpLoggingInterceptor
import retrofit2.Retrofit
import retrofit2.converter.moshi.MoshiConverterFactory
object NetworkModule {
private val moshi = Moshi.Builder()
.add(KotlinJsonAdapterFactory())
.build()
private val client = OkHttpClient.Builder()
.addInterceptor(HttpLoggingInterceptor().apply {
level = HttpLoggingInterceptor.Level.BODY
})
.build()
// Weather Service
private val weatherRetrofit = Retrofit.Builder()
.baseUrl("https://api.openweathermap.org/")
.client(client)
.addConverterFactory(MoshiConverterFactory.create(moshi))
.build()
val weatherService: WeatherService = weatherRetrofit.create(WeatherService::class.java)
// News Service
private val newsRetrofit = Retrofit.Builder()
.baseUrl("https://newsapi.org/v2/")
.client(client)
.addConverterFactory(MoshiConverterFactory.create(moshi))
.build()
val newsService: NewsService = newsRetrofit.create(NewsService::class.java)
// AI Service
private val aiRetrofit = Retrofit.Builder()
.baseUrl("https://api.openai.com/")
.client(client)
.addConverterFactory(MoshiConverterFactory.create(moshi))
.build()
val aiService: AiService = aiRetrofit.create(AiService::class.java)
}
7. DBModule
package com.codingbihar.openaiagent
import android.content.Context
import androidx.room.Database
import androidx.room.Room
import androidx.room.RoomDatabase
@Database(entities = [Message::class], version = 1)
abstract class AppDatabase : RoomDatabase() {
abstract fun messageDao(): MessageDao
}
object DBModule {
private var dbInstance: AppDatabase? = null
fun provideDatabase(context: Context): AppDatabase {
return dbInstance ?: synchronized(this) {
val instance = Room.databaseBuilder(
context.applicationContext,
AppDatabase::class.java,
"ai_agent_db"
).build()
dbInstance = instance
instance
}
}
}
package com.codingbihar.openaiagent
import android.content.Context
import androidx.room.Database
import androidx.room.Room
import androidx.room.RoomDatabase
@Database(entities = [Message::class], version = 1)
abstract class AppDatabase : RoomDatabase() {
abstract fun messageDao(): MessageDao
}
object DBModule {
private var dbInstance: AppDatabase? = null
fun provideDatabase(context: Context): AppDatabase {
return dbInstance ?: synchronized(this) {
val instance = Room.databaseBuilder(
context.applicationContext,
AppDatabase::class.java,
"ai_agent_db"
).build()
dbInstance = instance
instance
}
}
}
8. Message
package com.codingbihar.openaiagent
import androidx.room.Entity
import androidx.room.PrimaryKey
@Entity(tableName = "messages")
data class Message(
@PrimaryKey(autoGenerate = true) val id: Long = 0,
val sessionId: Long = 0, // เค
เคเคฐ multi-session เคाเคนिเค
val isUser: Boolean,
val text: String,
val timestamp: Long = System.currentTimeMillis()
)
9. MessageDao
package com.codingbihar.openaiagent
import androidx.room.Dao
import androidx.room.Insert
import androidx.room.Query
import kotlinx.coroutines.flow.Flow
@Dao
interface MessageDao {
@Insert
suspend fun insert(message: Message)
@Query("SELECT * FROM messages WHERE sessionId = :sessionId ORDER BY timestamp ASC")
fun getMessagesForSession(sessionId: Long): Flow>
@Query("DELETE FROM messages WHERE sessionId = :sessionId")
suspend fun deleteSessionMessages(sessionId: Long)
@Query("DELETE FROM messages")
suspend fun deleteAll()
}
10. DataModel
package com.codingbihar.openaiagent
import com.squareup.moshi.Json
data class WeatherResponse(
val name: String,
val main: Main,
val weather: List
)
data class Main(
val temp: Double,
@Json(name = "feels_like") val feelsLike: Double,
@Json(name = "temp_min") val tempMin: Double,
@Json(name = "temp_max") val tempMax: Double,
val pressure: Int,
val humidity: Int
)
data class Weather(
val id: Int,
val main: String,
val description: String,
val icon: String
)
data class AiResponse(
val choices: List?
)
data class AiChoice(
val message: AiMessage?
)
data class AiMessage(
val content: String?
)
fun AiResponse.outputText(): String {
return choices?.firstOrNull()?.message?.content?.trim() ?: "No response from AI"
}
data class NewsResponse(
val status: String,
val totalResults: Int,
val articles: List
) {
data class Article(
val source: Source?,
val author: String?,
val title: String?,
val description: String?,
val url: String?,
val urlToImage: String?,
val publishedAt: String?,
val content: String?
)
data class Source(
val id: String?,
val name: String?
)
}
data class AiRequest(
val model: String = "gpt-3.5-turbo", // เคฏा "gpt-4o-mini"
val messages: List
)
data class MessageRequest(
val role: String, // "user" | "system" | "assistant"
val content: String
)
data class ApiKeys(
val openWeatherKey: String,
val newsApiKey: String,
val aiKey: String
)
11. ApiService
package com.codingbihar.openaiagent
import retrofit2.http.Body
import retrofit2.http.GET
import retrofit2.http.Header
import retrofit2.http.POST
import retrofit2.http.Query
// -------------------- Retrofit Interfaces --------------------
interface WeatherService {
@GET("data/2.5/weather")
suspend fun getWeather(
@Query("q") city: String,
@Query("appid") apiKey: String,
@Query("units") units: String = "metric"
): WeatherResponse
}
interface NewsService {
@GET("top-headlines")
suspend fun getTopHeadlines(
@Query("country") country: String,
@Query("apiKey") apiKey: String
): NewsResponse
}
interface AiService {
@POST("https://openrouter.ai/api/v1/chat/completions")
suspend fun sendPrompt(
@Header("Authorization") apiKey: String,
@Body request: AiRequest
): AiResponse
}
12. Dependencies
// in build.gradleProject
id("com.google.devtools.ksp") version "2.0.21-1.0.27" apply false
// use in plugins
id("com.google.devtools.ksp")
// use inside dependencies {
implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.9.2")
implementation("io.coil-kt:coil-compose:2.7.0")
// Retrofit + Moshi
implementation("com.squareup.retrofit2:retrofit:2.9.0")
implementation("com.squareup.retrofit2:converter-moshi:2.9.0")
implementation("com.squareup.okhttp3:logging-interceptor:4.11.0")
implementation("com.squareup.moshi:moshi:1.15.0")
implementation("com.squareup.moshi:moshi-kotlin:1.15.0")
ksp("com.squareup.moshi:moshi-kotlin-codegen:1.15.0")
// Room with KSP
implementation("androidx.room:room-runtime:2.6.1")
implementation("androidx.room:room-ktx:2.6.1")
ksp("androidx.room:room-compiler:2.6.1")
// Optional: expression evaluator for calculator
implementation("net.objecthunter:exp4j:0.4.8")
Dependeny is most important part of the project. Do it first.
Output:
Wait...
๐ฏ Final Thoughts
What excites me most is how approachable this is. With under a few hundred lines of Kotlin code, you’ve got yourself a working AI agent app. It’s free to start, easy to expand, and the experience of combining AI with tools feels magical on mobile.
AI isn’t the future—it’s already here. And with Jetpack Compose, you can hold that future right in your hands.