← Back to list

AppFunctions: Making Your Android App Discoverable by AI Agents

A practical first look at Google’s new Jetpack API for exposing on-device app capabilities as tools an agent like Gemini can call.

Ioannis Anifantakis in ProAndroidDev · 2026-05-25 16:00 · 41 claps · 15.6 min read
#kotlin #android-app-development #androiddev #agentic-ai #android
Open on Medium ↗
Wiki topics: LLM · Large Language Models AGT · AI Agents 📱 · Mobile Development

AppFunctions: Making Your Android App Discoverable by AI Agents

A practical first look at Google’s new Jetpack API for exposing on-device app capabilities as tools an agent like Gemini can call.

Introduction

Google has quietly shipped the first public docs and the first usable alpha of AppFunctions, a Jetpack library paired with a new Android platform API that turns parts of your app into tools that AI agents can discover and execute.

If you have ever used the Model Context Protocol (MCP) on the server side, AppFunctions is the Android-native equivalent:

same idea, but the tool lives inside your app and runs locally on the device.

This article is an early hands-on walkthrough.

I added a minimal “hello world” AppFunction to an existing simple app I have been using as a teaching sample. A small jokes app with a Room database and an MVI presentation layer.

The entire change is in a single commit, and I will walk through it piece by piece so you can do the same in your own project today.

A note up front: AppFunctions is experimental. As of May 2026, Gemini integration is in a private preview with trusted testers.

The developer-side path, however, is open — you can build, register, and execute AppFunctions today using adb, which is exactly what we will do at the end of this article.

What MCP actually is

If you have not worked with the Model Context Protocol (MCP) before, a one-paragraph primer will make everything that follows easier.

MCP is an open standard, originally published by Anthropic in late 2024 and now adopted by a growing number of AI products, that defines a single way for AI agents to connect to external tools and data sources. The model is straightforward:

  • An MCP server is a small process that exposes a list of tools. Each tool has a name, a plain-language description, and a JSON Schema that describes its parameters and return value.
  • An MCP client (typically the AI agent) connects to the server, asks “what tools do you have?”, reads the descriptions, and decides which tool to call based on the user’s request.
  • The conversation between them is JSON-RPC, carried over stdio (for local servers) or HTTP (for remote ones).

Before MCP, each integration was customised. That is, every AI product had its own method of connecting tools. With MCP, a single server that exposes “search my email” can be consumed by any MCP-compatible agent without requiring integration changes on either side.

The challenge is that traditional MCP servers operate outside your application, often as separate processes or cloud services. They lack privileged access to your app’s state. If you wanted an MCP-style tool that can read an unsent draft inside a note-taking app, you would either need to expose that draft through an API or run the MCP server within the app’s own process. Neither option is convenient on mobile.

That is the gap AppFunctions fills.

What AppFunctions actually is

In short: AppFunctions is best understood as an MCP for Android, but on-device.

Your app declares a class, annotates some of its methods with @AppFunction, and the Jetpack annotation processor generates the metadata and the wiring needed for the OS to index those methods. From that moment on, any caller with the EXECUTE_APP_FUNCTIONS permission (including system agents) can discover your function, read its description, and invoke it with parameters.

Three properties make this interesting:

  1. It is local. No network round-trip, no server to maintain. The agent calls your app directly and reads its current state.
  2. It is indexed by the OS. You do not register anything at runtime. The OS reads a generated XML schema at install time and maintains a registry that agents can query.
  3. The function’s documentation becomes part of its contract. When you mark a function with @AppFunction(isDescribedByKDoc = true), your KDoc is encoded into the function's metadata and shown to the agent. Writing good KDoc stops being only a documentation concern. From now on, it becomes a runtime concern.

Minimum requirements: compileSdk = 36, devices running Android 16 or higher.

The sample app in 60 seconds

The base app is a small Compose application that fetches jokes from a remote source using Ktor and lets the user mark favorites. It uses Room for offline cache and local favorites storage and a typical layered architecture: DAO, data source, repository, view model, screen. Nothing here is unusual; it is the same skeleton most production Android apps have.

The feature I added is intentionally tiny: clear all favorite jokes. The exact same capability is reachable in two ways:

  • The user can tap a menu item in the top bar.
  • An AI agent can call the clearFavorites AppFunction.

Both paths end up calling the same repository method. That is the principle I want to leave you with before we even look at code: an AppFunction is a thin shell over normal app logic, never a duplicate of it.

Step 1 — The dependencies

Open gradle/libs.versions.toml and add the AppFunctions artifacts. The current alpha is 1.0.0-alpha09:

[versions]
appfunctions = "1.0.0-alpha09"

[libraries]
androidx-appfunctions          = { module = "androidx.appfunctions:appfunctions",          version.ref = "appfunctions" }
androidx-appfunctions-service  = { module = "androidx.appfunctions:appfunctions-service",  version.ref = "appfunctions" }
androidx-appfunctions-compiler = { module = "androidx.appfunctions:appfunctions-compiler", version.ref = "appfunctions" }

Then in app/build.gradle.kts:

ksp {
    arg("appfunctions:aggregateAppFunctions", "true")
}

dependencies {
    // App Functions
    implementation(libs.androidx.appfunctions)
    implementation(libs.androidx.appfunctions.service)
    ksp(libs.androidx.appfunctions.compiler)
}

Two things to know:

  • The KSP arg line instructs the AppFunctions compiler to aggregate all the AppFunctions declared in your app into a single schema. In a multi-module project, you only set this argument in the application module; the modules that contain AppFunctions only need the compiler dependency.
  • You also need at least compileSdk = 36. If you are still on 35, this is a one-line bump in your build file.

Step 2 — Manifest plumbing

The OS needs to know two things at install time:

  1. Where to read your app’s AppFunction metadata,
  2. Which service to bind when an agent wants to execute one of your functions.

Both go inside <application> in AndroidManifest.xml:

<application
    ...>

<property
        android:name="android.app.appfunctions.app_metadata"
        android:resource="@xml/app_metadata" />
    <service
        android:name="androidx.appfunctions.service.PlatformAppFunctionService"
        android:permission="android.permission.BIND_APP_FUNCTION_SERVICE"
        android:exported="true"
        tools:targetApi="36">
        <intent-filter>
            <action android:name="android.app.appfunctions.AppFunctionService" />
        </intent-filter>
    </service>
    <!-- your activities here -->
</application>

What each piece does:

  • The <property> element points the OS to app_metadata.xml, which carries the app-level description; that is what this app is about as a whole, so the AI agents can know what each app is about. Per-function descriptions are a separate concern: they come from the KDoc on each @AppFunction method.
  • The <service> declaration exposes the PlatformAppFunctionService, which is the bridge the platform uses to invoke your functions. Luckily, you do not write this service yourself; it ships in the appfunctions-service library. You only need to declare it in the manifest with the right permission and intent filter so the system can find and bind to it.

The BIND_APP_FUNCTION_SERVICE permission ensures only the platform can bind to the service. You do not need to request EXECUTE_APP_FUNCTIONS; that is the caller's permission, not yours.

Step 3 — Describe the app itself in app_metadata.xml

The manifest’s <property> element points at res/xml/app_metadata.xml.

As the file name suggests, this file contains the app-level description that the OS exposes to agents, along with the schemas for individual functions. Here is the one I added to the sample app:

<?xml version="1.0" encoding="utf-8"?>
<AppFunctionAppMetadata
    xmlns:appfn="http://schemas.android.com/apk/androidx.appfunctions"
    appfn:description="This app allows users to view and manage jokes, including marking them as favorites and clearing the favorites list." />

The structure is intentionally small for now — a single root element, a single description attribute. But the meaning is bigger than the syntax.

AppFunctions actually gives the agent two layers of documentation, and it is worth being explicit about how they relate:

[embed]

This is the same hierarchical model MCP uses on the server side; an MCP server has a description, and each tool within it has its own. The agent uses the high-level description first to decide “is this even the right app to look inside?”, then drills into the function descriptions to pick the right one within that app.

So treat appfn:description the same way I urged you to treat your KDoc: write it as runtime input to an LLM, not as marketing copy. Short, concrete, focused on the verbs the app supports ("view and manage jokes", "marking as favorites", "clearing the favorites list") rather than on positioning ("the best joke companion on Android"). Be honest about what the app does, because that is what the agent will trust when it has to choose between three different apps that all advertise "jokes".

The schema published today exposes only the description attribute. The release notes for the androidx.appfunctions library are the place to track what else lands as the API stabilizes.

Step 4 — Extend the data layer (the boring, normal part)

Before we touch any AppFunctions code, we add the new business capability through the existing layers. The point I made earlier (that an AppFunction is a thin shell over normal logic) only works if that normal logic exists first.

DAO (JokesDao.kt):

@Query("UPDATE joke SET isFavorite = 0")
suspend fun clearAllFavorites()

Local data source (LocalJokesDataSource.kt and its implementation):

interface LocalJokesDataSource {
    // ...
    suspend fun clearAllFavorites()
}

class LocalJokesDataSourceImpl(/* ... */) : LocalJokesDataSource {
    override suspend fun clearAllFavorites() {
        database.clearAllFavorites()
    }
}

Repository (JokesRepository.kt and its implementation):

interface JokesRepository {
    // ...
    suspend fun clearAllFavorites(): Result<Unit>
}

class JokesRepositoryImpl(/* ... */) : JokesRepository {
    override suspend fun clearAllFavorites(): Result<Unit> {
        return safeCall {
            localDataSource.clearAllFavorites()
        }
    }
}

Nothing here knows or cares about AppFunctions. It is just normal Android architecture, and that is the point.

Step 5 — The AppFunction itself

Now we add the actual function the agent will call. This is a plain Kotlin class; no inheritance, no Android lifecycle:

package eu.anifantakis.networkapp.jokes.features.jokes.appfunctions

import androidx.appfunctions.AppFunctionContext
import androidx.appfunctions.service.AppFunction
import eu.anifantakis.networkapp.jokes.di.AppModule
/**
 * App Functions that can be exposed to AI agents via MCP.
 */
class JokesAppFunctions {
    /**
     * Unmarks every joke the user has previously marked as a favorite, leaving the favorites list empty.
     *
     * Only the favorite flag is affected. The underlying jokes remain in the database and are still
     * visible in the main list. This operation is safe to repeat: calling it on an already-empty favorites
     * list is a no-op. It is irreversible: cleared favorite markers cannot be restored.
     *
     * @return A short human-readable status message describing whether the operation succeeded.
     */
    @AppFunction(isDescribedByKDoc = true)
    suspend fun clearFavorites(context: AppFunctionContext): String {
        val repository = AppModule.jokesRepository
        val result = repository.clearAllFavorites()
        return if (result.isSuccess) {
            "All favorite jokes have been cleared successfully."
        } else {
            "Failed to clear favorite jokes: ${result.exceptionOrNull()?.message}"
        }
    }
}

Things worth pausing on:

isDescribedByKDoc = true

The KDoc above is very useful for the agent. It clearly explains what changes are involved (the favorite flag), what remains unchanged (the underlying jokes), whether the operation is safe to retry (yes, calling it again does no extra harm), and whether it can be undone (no). An agent reading this information has enough to decide whether to call this function or ask the user for confirmation first. In comparison, a phrase like “Removes favorites” has the same aim but lacks details; the agent would not understand what “remove” means or if the operation can be reversed.

The same care should also apply to parameter names and types once your function takes any. A parameter called filter: String reads as concrete to a human navigating a UI, but as a black hole to an LLM reading a schema.

Names like daysOlderThan: Int or category: JokeCategory carry their meaning forward; vague names quietly widen the space for the agent to guess wrong. Schemas your UI forgave will silently fail here.

The first parameter is always AppFunctionContext

The system passes this in; you do not. It is your hook for accessing system services and identifying the caller.

The return type here is a String

The agent will treat that text as the result it can show back to the user. For more sophisticated functions, you would return a serializable data class annotated with @AppFunctionSerializable — the agent then receives structured data and can format it however it wants. For a “Hello World” example like this one, a sentence of plain text is enough.

This function is suspend

That is not optional. By default, AppFunction implementations run on the main thread, so any I/O must suspend. In our case, the repository call already handles dispatching internally.

A note on dependencies: this class has a no-arg constructor and reaches into AppModule.jokesRepository directly.

That works for a hello world. In a real codebase you would constructor-inject the repository through Hilt or Koin, and then provide a factory so the OS knows how to instantiate the class, which brings us to the next step.

Step 6 — Tell the OS how to build your AppFunction class

The OS, not your code, instantiates your AppFunctions class when an agent calls into it. So you need to declare a factory for it. The hook is AppFunctionConfiguration.Provider, implemented on your Application subclass:

package eu.anifantakis.networkapp

import android.app.Application
import androidx.appfunctions.service.AppFunctionConfiguration
import eu.anifantakis.networkapp.jokes.di.AppModule
import eu.anifantakis.networkapp.jokes.features.jokes.appfunctions.JokesAppFunctions
class MyApplication : Application(), AppFunctionConfiguration.Provider {
    override fun onCreate() {
        super.onCreate()
        AppModule.initialize(applicationContext)
    }
    override val appFunctionConfiguration: AppFunctionConfiguration
        get() = AppFunctionConfiguration.Builder()
            .addEnclosingClassFactory(JokesAppFunctions::class.java) { JokesAppFunctions() }
            .build()
}

addEnclosingClassFactory takes the class that contains your AppFunctions and a lambda that knows how to build an instance. For multiple AppFunction classes you chain multiple addEnclosingClassFactory calls before calling build(). With Hilt, you would inject the class through the field and return the injected instance from the lambda — Google's official docs show exactly that pattern.

Do not forget to register MyApplication in your manifest:

<application
    android:name=".MyApplication"
    ...>

Step 7 — Test it (adb, a real agent, or both)

This is where most readers will pause and ask the right question: I have built and registered an AppFunction, so how do I actually see an agent call it?

There are three answers, each useful for a different reason.

Route A — adb (the developer shortcut)

The fastest way to verify your wiring is through adb shell cmd app_function. It is worth being precise about what this command does, because it is not a fake or a mock — it is a thin shell front-end to the same OS-level AppFunctionService that any agent would talk to through AppFunctionManager from Kotlin code.

You are not simulating an agent here; you are bypassing it and speaking to the OS directly. That makes adb the ideal tool for end-to-end verification, because it removes every AI-product variable and leaves only your own wiring under test.

Build and install the app on a device or emulator running Android 16 or higher, then list the registered AppFunctions:

# You Type this for the first 10 lines of app functions for our package
adb shell cmd app_function list-app-functions | grep -A 10 "eu.anifantakis.networkapp.jokes"

If everything compiled and the manifest is set up correctly, you should see something like this:

# OUTPUT of first 10 lines:
"eu.anifantakis.networkapp.jokes.features.jokes.appfunctions.JokesAppFunctions#clearFavorites"
        ],
        "packageNameHash": [
          -1179891122
        ],
        "scope": [
          "global"
        ],
        "mobileApplicationQualifiedId": [
          "android$apps-db\/apps#eu.anifantakis.networkapp"
        ],
--
            "android$apps-db\/app_functions#eu.anifantakis.networkapp\/eu.anifantakis.networkapp.jokes.features.jokes.appfunctions.JokesAppFunctions\\#clearFavorites"
          ],
          "functionId": [
            "eu.anifantakis.networkapp.jokes.features.jokes.appfunctions.JokesAppFunctions#clearFavorites"
          ],
          "packageName": [
            "eu.anifantakis.networkapp"
          ]
        }
      }
    }
  ],
  "com.google.android.permissioncontroller": [
    {
ioannisanif@192 MitropolitikoNetworkApp %

A few things worth pointing out in this output:

  • **functionId** is the canonical identifier of our AppFunction: eu.anifantakis.networkapp.jokes.features.jokes.appfunctions.JokesAppFunctions#clearFavorites. Note the # separator between the fully-qualified class name and the method name. This is exactly the string you pass to --function in the next command, so copy it from this output rather than typing it from memory.
  • **packageName* is eu.anifantakis.networkapp — the app's applicationId. This is what you pass to --package, and it is not* the same as the package containing the function class. (The function class lives at eu.anifantakis.networkapp.jokes.features.jokes.appfunctions, but the application identifier is just eu.anifantakis.networkapp.)
  • **mobileApplicationQualifiedId** points at android$apps-db/apps#eu.anifantakis.networkapp. That apps-db prefix is the OS-level database — AppSearch, internally — that the system uses to index installed apps. Our function has its own entry under app_functions within that same database.
  • **scope: global** confirms the function is visible without further gating. If we had used @AppFunction(isEnabled = false, ...) and not yet enabled it at runtime, this entry would not show up here at all.

The point worth taking away is that this output is not a diagnostic surface dressed up to look pretty for developers. It is a literal view into the OS’s AppFunctions registry; the same registry an agent reads when it asks “what functions does this app expose?”. Everything an agent will see about your function is in here.

Try it end-to-end:

To actually see the function in action, run through this short demo:

  1. Run the app and mark some jokes as favorites by tapping the heart icon next to the ones you want to keep.
  2. Close and reopen the app. You should notice that while the rest of the jokes are refreshed from the network, the favorites you marked are preserved.
  3. Run the script below and watch the favorite marks instantly clear from your screen. Note, you don’t need to run your app for this to take effect.
adb shell cmd app_function execute-app-function \
  --package eu.anifantakis.networkapp \
  --function eu.anifantakis.networkapp.jokes.features.jokes.appfunctions.JokesAppFunctions#clearFavorites \
  --parameters '{}'

If your favorites table had rows marked isFavorite = 1 before, they are all reset to 0 now, and adb prints the string the function returned.

Important Observation also just mentioned above:

This works whether your app is in the foreground, in the background, or completely closed.

The OS reaches your function through the AppFunctionService independently of your activity lifecycle. That is the practical cash-value of "indexed by the OS" from the start of this article.

Agents do not need your UI to be alive in order to reach your code.

Other paths (I have not tried yet)

adb is the one route I have actually used to verify this hello world. There are two other ways an external caller can reach an AppFunction, and both are worth knowing exist, even if I cannot speak to them yet from first-hand experience.

Route B — A custom host app that uses AppFunctionManager.

This is the closest you can get to the production shape: a second app on the device whose job is to discover and invoke functions exposed by another app over IPC, with no adb in the middle.

It is also the pattern Google themselves demonstrated at I/O when AppFunctions was first shown publicly, because there was not yet a real agent to demo, they built a small host app for the purpose.

The catch is that EXECUTE_APP_FUNCTIONS is currently a privileged permission on Android 16 release builds, so a host app cannot be installed and granted that permission via a normal adb install; you need a userdebug build, a rooted device, or a /system/priv-app install.

I have not built this end-to-end myself yet, so I am not going to pretend to walk through it here, but a follow-up article is on schedule that will showcase a host app calling into the jokes AppFunction we wrote in this one.

Route C — Gemini in Android Studio as an LLM-in-the-loop check.

Google’s docs suggest using Gemini in Android Studio with a prompt that asks it to “Execute adb shell cmd app_function to learn how the tool works, then act as a chat agent...".

In effect this has an LLM drive adb for you, which tests whether your function descriptions are clear enough for a model to pick the right one. I have not personally tried this either, so I cannot say more than what the docs document.

What about Gemini on a real phone?

As of this writing, full Gemini integration with AppFunctions is in a private preview with trusted testers. You cannot just install your app, open Gemini on your phone, and have it call into your functions yet.

A note on destructive functions

The clearFavorites function in this article happens to be safe to repeat by accident. UPDATE SET isFavorite = 0 produces the same result whether called once or ten times. That accident is worth pausing on, because the moment you write a destructive AppFunction where repeating the call would cause harm, you have reopened every problem REST solved for deterministic callers, with one new wrinkle: the caller is now an LLM that can hallucinate, retry, or pick your function for the wrong reasons.

A deleteJoke(id) called twice is a bug at best. A sendPayment(amount) called twice is a real problem. Before you annotate a write with @AppFunction, treat the same questions REST forces you to answer: what happens under retry, what happens under partial failure, and who is allowed to call this in the first place?

A few patterns worth carrying over from the REST era:

1) Make writes safe to repeat by design where you can

  • Prefer absolute set operations to deltas,
  • accept a client-supplied request ID so a duplicate call can be detected and ignored,
  • return success identically for a no-op and a real change

Let’s see these bullets in more detail…

> Prefer absolute set operations over deltas

An UPDATE that writes a final value, like SET status = 'active' gives the same result every time it runs.

A delta like SET value = value + 1 - accumulates with every retry, so two calls leave the counter at +2 even though the caller only meant +1.

For AppFunctions, markAsFavorite(jokeId) is absolute and safe to repeat; incrementFavoriteCount(jokeId) is a delta and is not.

> Accept a client-supplied request ID

Let the caller send a unique key with every call.

Your function keeps a small record of recently-seen keys; if the same key arrives twice, you return the cached result of the first call instead of running the operation again.

This is exactly how payment APIs like Stripe survive network retries without double-charging. They require an Idempotency-Key header on every request, and you can borrow the same pattern for any sensitive write.

> Return success the same way, whether the operation did real work or was a no-op

Our clearFavorites already does this.

Both branches return “All favorite jokes have been cleared successfully,” regardless of whether ten jokes were unfavorited or zero.

Different messages (“Cleared 10” vs “Nothing to clear”) would leak state to the caller and tempt the LLM to pick a different next step depending on the answer.

2) Expose reads liberally, expose writes conservatively.

There is no rule that says every method on your repository deserves an @AppFunction. Pick the smallest surface that is genuinely useful to an agent.

3) Assume nothing is guarding the door

EXECUTE_APP_FUNCTIONS is privileged today, but the enforcement story between agent and function is still being defined. The follow-up host-app article will dig into that layer in detail. Until then, your function is the last layer of defence, not the first.

There is a strategic point underneath all of this that is worth saying out loud. We spent a decade optimising deep links, App Indexing, and search to get users into the app. AppFunctions optimises for the opposite — the app never opening, the screen never lighting up, no UI between the agent and your business logic. That changes what “least privilege” looks like in practice: the UI is no longer there to ask “are you sure?” on your behalf.

Closing thought

The line of code I keep coming back to is this one:

@AppFunction(isDescribedByKDoc = true)

That single flag turns documentation into runtime behavior. The KDoc you write is the contract an LLM reads to decide whether to call your function and what to pass into it. Vague KDoc means a confused agent. Precise KDoc, with the same care you would give to a public API description, means the agent picks your function for the right reasons.

That, more than any specific annotation or manifest entry, is the shift AppFunctions is asking us to make.

Useful links


메타데이터
post_id
fbfbeddf8103
slug
appfunctions-making-your-android-app-discoverable-by-ai-agents-fbfbeddf8103
url
https://proandroiddev.com/appfunctions-making-your-android-app-discoverable-by-ai-agents-fbfbeddf8103
canonical_url
https://proandroiddev.com/appfunctions-making-your-android-app-discoverable-by-ai-agents-fbfbeddf8103
author_url
https://medium.com/@ioannisanif
status
ok
fetched_at
2026-06-09 15:37:30