---
title: "Wiring LLM Tool Calls to Android's WorkManager: Reliable Agentic Pipelines"
published: true
description: "Model each LLM tool-call iteration as a CoroutineWorker, chain them with WorkManager, propagate progress via LiveData, and gate execution with Constraints — so agentic pipelines survive OOM kills and Doze mode."
tags: android, kotlin, architecture, mobile
canonical_url: https://mvpfactory.co/blog/llm-tool-calls-workmanager-android
---
## What We Are Building
By the end of this tutorial you will have a WorkManager-backed agentic pipeline that maps each LLM tool-call iteration to a discrete, restartable `CoroutineWorker`. The chain will survive backgrounding, OOM kills, and Doze mode, report live progress through `LiveData`, and respect battery constraints. Let me show you a pattern I use in every project that involves long-running background work on Android.
## Prerequisites
- Android project targeting API 23+
- `androidx.work:work-runtime-ktx` added to your dependencies
- Familiarity with Kotlin coroutines
- A basic understanding of how LLM tool-call loops work (prompt → response → tool dispatch → result injection → repeat)
---
## The Problem Most Teams Get Wrong
Most teams wire the entire tool-call loop inside a `ViewModel` coroutine or a foreground `Service`, then wonder why the pipeline dies after 90 seconds when the user locks the screen.
The Android documentation spells this out in three places. [App Standby](https://developer.android.com/topic/performance/appstandby) buckets inactive apps into tiers that progressively defer or deny wakelocks and network access. [Doze mode](https://developer.android.com/training/monitoring-device-state/doze-standby) defers scheduled alarms and network access the moment a device goes stationary and unplugged. [Background execution limits](https://developer.android.com/about/versions/oreo/background) turn your `Service` into a ticking clock. WorkManager was built for exactly this class of problem.
---
## Step 1 — Model Each Iteration as a Worker
Treat each LLM iteration as a discrete, restartable unit of work. A depth guard prevents runaway chains:
kotlin
class LlmToolCallWorker(
context: Context,
params: WorkerParameters
) : CoroutineWorker(context, params) {
override suspend fun doWork(): Result {
val sessionId = inputData.getString("session_id") ?: return Result.failure()
val toolResult = inputData.getString("tool_result")
val depth = inputData.getInt("depth", 0)
if (depth > MAX_ITERATIONS) {
return Result.failure(workDataOf("error" to "max_iterations_exceeded"))
}
setProgress(workDataOf("status" to "invoking_llm"))
val response = llmClient.complete(sessionId, toolResult)
return when {
response.requiresToolCall -> {
val toolOutput = try {
withTimeout(TOOL_TIMEOUT_MS) { dispatchTool(response.toolCall) }
} catch (e: TimeoutCancellationException) {
return Result.retry()
} catch (e: Exception) {
return Result.retry()
}
val nextWork = OneTimeWorkRequestBuilder<LlmToolCallWorker>()
.setInputData(workDataOf(
"session_id" to sessionId,
"tool_result" to toolOutput,
"depth" to depth + 1
))
.setBackoffCriteria(BackoffPolicy.EXPONENTIAL, 10, TimeUnit.SECONDS)
.build()
WorkManager.getInstance(applicationContext).enqueue(nextWork)
Result.success()
}
response.isFinal -> Result.success(workDataOf("output" to response.text))
else -> Result.retry()
}
}
companion object {
const val MAX_ITERATIONS = 20
const val TOOL_TIMEOUT_MS = 30_000L
}
}
The depth counter travels through `inputData`, making the chain self-terminating. The docs do not mention this, but a hanging `dispatchTool()` call will silently stall your worker — the `withTimeout` wrapper is non-negotiable.
---
## Step 2 — Gate Execution with Constraints
Agentic pipelines make network calls by definition. This is the difference between a 4.5-star app and a 2-star review about battery drain:
kotlin
val constraints = Constraints.Builder()
.setRequiredNetworkType(NetworkType.CONNECTED)
.setRequiresBatteryNotLow(true)
.build()
val initialWork = OneTimeWorkRequestBuilder()
.setConstraints(constraints)
.setInputData(workDataOf(
"session_id" to newSessionId(),
"depth" to 0
))
.build()
Apps like HealthyDesk — which runs background scheduling logic to deliver break reminders at the right moment — apply this same constraint pattern to avoid pestering users on a dying battery. Agentic workloads are no different in the OS's eyes.
---
## Step 3 — Report Progress via LiveData
`setProgress()` belongs in the Worker. Observation belongs in the ViewModel:
kotlin
WorkManager.getInstance(context)
.getWorkInfoByIdLiveData(workRequest.id)
.observe(viewLifecycleOwner) { info ->
val status = info?.progress?.getString("status") ?: return@observe
updateUi(status) // "invoking_llm", "dispatching_tool", "complete"
}
`WorkInfo` exposes both `progress` (intermediate) and `outputData` (terminal). Observe both. The UI stays reactive without polling, and the observation lifecycle is tied to the view, not the Worker's execution context.
---
## Gotchas
Here is the gotcha that will save you hours: **never treat the whole tool-call loop as one atomic operation**. A single `CoroutineWorker` with an internal `while` loop gives you none of WorkManager's retry guarantees on individual steps. Each LLM iteration must be its own `OneTimeWorkRequest`.
Two more to watch for:
- **Missing depth guard**: Without `MAX_ITERATIONS = 20`, a model that repeatedly requests tool calls will enqueue workers indefinitely. Pass the counter through `inputData` on every hop.
- **Observing in the Worker**: Calling `getWorkInfoByIdLiveData` inside `doWork()` ties observation to the wrong lifecycle. Always observe from the ViewModel or Fragment layer.
---
## Approach Comparison
| Approach | Survives OOM kill | Survives Doze | Retry logic | Battery safe |
|---|---|---|---|---|
| ViewModel coroutine | No | No | Manual | No |
| Foreground Service | Partial | Partial | Manual | Risky |
| JobScheduler | Yes | Yes | Limited | Yes |
| WorkManager (chained) | Yes | Yes | Built-in | Yes |
WorkManager is the only option that doesn't require you to rebuild what the OS already provides.
---
## Conclusion
Agentic pipelines aren't fire-and-forget HTTP calls. They're stateful, multi-step processes that need persistence guarantees. WorkManager's chained `CoroutineWorker` model maps cleanly onto the tool-call loop structure that modern LLM APIs expose.
Three things to internalize before you ship:
1. Model each LLM iteration as a discrete Worker with a depth counter enforcing `MAX_ITERATIONS`.
2. Always set `NetworkType.CONNECTED` and `setRequiresBatteryNotLow(true)` — agentic work has no business running on a dying battery over a flaky connection.
3. Keep progress observation in the ViewModel layer. Separate execution from UI state.
The minimal setup above is production-ready. Ship it, then tune the backoff and timeout values against your specific tool latency profile.
Top comments (0)