LLM tool calls + WorkManager on Android
Meta description: Learn how to wire multi-step LLM tool-call loops into WorkManager chains with retry policies, LiveData progress, and constraint scheduling for reliable agentic pipelines.
Tags: android kotlin architecture mobile backend
TL;DR
Long-running LLM agentic loops die silently on Android. Model each tool-call iteration as a CoroutineWorker, chain them with WorkManager, propagate progress via setProgress() + LiveData, and gate execution with Constraints. Your pipeline survives backgrounding, OOM kills, and Doze mode without burning the battery or the user’s patience.
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 their screen.
LLMs operating as multi-step reasoning engines run each step by invoking a tool (file read, web search, API call), parsing the result, and deciding the next action. On a server, that loop runs uninterrupted. On Android, the OS actively kills anything it considers idle. Your coroutine scope doesn’t survive an OOM kill. Your Service gets throttled in Doze mode.
The Android documentation spells this out in three places. App Standby buckets inactive apps into tiers that progressively defer or deny wakelocks and network access. Doze mode defers scheduled alarms, network access, and syncs the moment a device goes stationary and unplugged. Background execution limits turn your Service into a ticking clock. WorkManager was built for exactly this class of problem.
Modeling the tool-call loop as a worker chain
Treat each LLM iteration (prompt → response → tool dispatch → result injection) as a discrete, restartable unit of work. Add a depth guard to prevent runaway chains in pathological cases:
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
}
}
Each worker enqueues its successor only after successfully completing its own step. The depth counter travels through inputData, making the chain self-terminating. dispatchTool() gets both a timeout and a general catch — a hanging tool call returns Result.retry() rather than crashing the worker silently.
Constraint-based scheduling: respect the device
Agentic pipelines make network calls by definition. Gate them:
val constraints = Constraints.Builder()
.setRequiredNetworkType(NetworkType.CONNECTED)
.setRequiresBatteryNotLow(true)
.build()
val initialWork = OneTimeWorkRequestBuilder<LlmToolCallWorker>()
.setConstraints(constraints)
.setInputData(workDataOf(
"session_id" to newSessionId(),
"depth" to 0
))
.build()
This is the difference between a 4.5-star app and a 2-star review about battery drain. Email clients, photo backup services, code analysis tools — production Android apps doing any kind of deferred background sync use this same pattern. Agentic workloads are no different in the OS’s eyes.
Progress reporting via LiveData
setProgress() is called inside the Worker, which is exactly where it belongs. The question is where you observe it:
// In your ViewModel, not in the Worker
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.
Comparison: approaches to long-running agentic work
| 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. BackoffPolicy.EXPONENTIAL, a depth guard, constraint-gated scheduling, and setProgress() LiveData together give you a pipeline that respects the user’s battery and the OS’s process lifecycle contracts.
Three things worth internalizing before you ship this:
- Model each LLM iteration as a discrete Worker. Never treat the whole tool-call loop as one atomic operation. Pass a depth counter through
inputDataand enforceMAX_ITERATIONSto keep chains bounded. - Always set
NetworkType.CONNECTEDandsetRequiresBatteryNotLow(true)constraints. Agentic work has no business running on a dying battery over a flaky connection. - Keep progress observation in the ViewModel layer.
setProgress()belongs in the Worker, butgetWorkInfoByIdLiveDatashould be observed in the ViewModel or Fragment. Separate execution from UI state.