MVP Factory
ai startup development

LLM tool calls + WorkManager on Android

KW
Krystian Wiewiór · · 5 min read

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

ApproachSurvives OOM killSurvives DozeRetry logicBattery safe
ViewModel coroutineNoNoManualNo
Foreground ServicePartialPartialManualRisky
JobSchedulerYesYesLimitedYes
WorkManager (chained)YesYesBuilt-inYes

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 inputData and enforce MAX_ITERATIONS to keep chains bounded.
  • Always set NetworkType.CONNECTED and setRequiresBatteryNotLow(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, but getWorkInfoByIdLiveData should be observed in the ViewModel or Fragment. Separate execution from UI state.

Share: Twitter LinkedIn