
Declare app capabilities once in shared code; generates native assistant glue, wrappers, entities and a runtime dispatch bridge, eliminating handwritten wiring and failing fast on unsupported types.
Android (Gemini) and iOS (Siri / Apple Intelligence) are converging on the same idea: apps expose structured, self-describing functions that on-device AI agents can discover and invoke. Android calls it AppFunctions; Apple calls it App Intents. The two APIs are conceptually identical — annotated function + typed params + description + entity types — but completely incompatible in implementation. So a KMP team today writes and maintains two parallel capability layers.
Kapability collapses that into one. A Gradle plugin + KSP processor + thin runtime that turns a single
@Capability declaration in commonMain into native glue for both platforms at build time.
[!NOTE] Status: early / pre-release. Proven end-to-end on both platforms; while pre-1.0 the public API may still change. (Current version: the Maven Central badge above.)
commonMain, native on Android and iOS.@AppFunction wrapper, @AppFunctionSerializable types, the Swift AppIntent + AppEntity, and the dispatch bridge are all generated.androidx.appfunctions compiler and Xcode's App Intents extraction; no reimplemented metadata formats.@Capability(platforms = [ANDROID]) for platform-specific capabilities.plugins { id("dev.kotforge.kapability") }.Artifacts are on Maven Central and the plugin is on the Gradle Plugin Portal, so the default repositories are enough:
// settings.gradle.kts
dependencyResolutionManagement {
repositories {
google()
mavenCentral()
}
}Your KMP module (where capabilities live):
// shared/build.gradle.kts
plugins {
kotlin("multiplatform")
id("com.android.library")
id("co.touchlab.skie") // required: Kotlin suspend -> Swift async bridge
id("dev.kotforge.kapability") version "0.2.0"
}Your Android app module:
// androidApp/build.gradle.kts
plugins {
id("com.android.application")
kotlin("android")
id("dev.kotforge.kapability") version "0.2.0"
}
kapability {
capabilityProject.set(project(":shared"))
}That's it — the plugin adds the kapability-annotations / kapability-runtime dependencies, wires the
KSP processor + the androidx.appfunctions two-pass, and manages the generated sources for you.
Write this once, in commonMain:
import dev.kotforge.kapability.annotations.*
@CapabilityEntity(description = "A note in the app")
data class Note(
@CapabilityId val id: String,
val title: String,
val content: String,
val priority: Int,
)
class NoteCapabilities(private val repo: NoteRepository) {
@Capability(description = "Create a note with the given title, content and priority")
suspend fun createNote(
@CapabilityParam(description = "Title of the note") title: String,
@CapabilityParam(description = "Body content of the note") content: String,
@CapabilityParam(description = "Priority from 1 (low) to 5 (high)") priority: Int,
): Note = repo.create(title, content, priority)
}…and Kapability generates, at build time:
| Platform | Generated | Reaches |
|---|---|---|
| 🤖 Android |
@AppFunction wrapper + @AppFunctionSerializable types → androidx.appfunctions
|
Gemini (AppSearch-indexed) |
| 🍎 iOS | Swift struct CreateNoteIntent: AppIntent + AppEntity (via SKIE async) |
Siri / Shortcuts / Spotlight |
Both dispatch through one generated KapabilityRuntime.invoke() entry point into your shared code. Wire
your capability instance once at startup (KapabilityRuntime.install(NoteCapabilities(repo))), and you're done.
commonMain: @Capability
│ Kapability KSP processor
▼
┌──────────────────────────────────────────────┐
│ generated KapabilityRuntime.invoke() (common) │
└───────────────┬────────────────┬──────────────┘
Android glue │ │ iOS glue
@AppFunction │ │ Swift AppIntent
@AppFunctionSerializable │ AppEntity (SKIE async)
▼ ▼
androidx.appfunctions App Intents runtime
(Gemini) (Siri · Shortcuts · Spotlight)
| Artifact | Purpose |
|---|---|
dev.kotforge:kapability-annotations |
Public API — @Capability, @CapabilityEntity, @CapabilityParam, @CapabilityId, Platform
|
dev.kotforge:kapability-runtime |
The invoke() bridge types + error model |
dev.kotforge:kapability-processor |
KSP2 processor (code generation) |
dev.kotforge.kapability (Gradle plugin)
|
Wires everything into your build |
What each on-device platform supports natively, and what Kapability carries today — as
parameters and @CapabilityEntity properties, returning a @CapabilityEntity. Values travel
across a JSON bridge; anything Kapability doesn't support fails the build with a clear error.
| Type | Android AppFunctions | iOS App Intents | Kapability | How Kapability carries it |
|---|---|---|---|---|
String · Int · Double · Boolean
|
✅ | ✅ | ✅ | JSON scalar → native type on both sides |
Long · Float
|
✅ | ✅ | 🕓 | planned |
| Enum | ❌ 1 | ✅ AppEnum
|
✅ | enum name as a JSON string → Swift AppEnum on iOS, String on the Android surface |
List<String> |
✅ | ✅ [String]
|
✅ | JSON array → List<String> / [String]
|
List<Int> · List<Double> · List<Boolean>
|
✅ [T]
|
🕓 | planned — needs IntArray/… mapping on Android |
|
Nullable (T?) |
✅ | ✅ optional | ✅ | JSON null / absent → nullable type on both sides |
Date |
✅ java.time.*
|
✅ Date
|
🕓 | planned — needs kotlinx-datetime + mapping |
@CapabilityEntity (return type) |
✅ @AppFunctionSerializable
|
✅ AppEntity
|
✅ | generated @AppFunctionSerializable / AppEntity, one level deep |
Nested @CapabilityEntity
|
✅ 3 | ✅ | 🕓 | planned — recursive codegen |
List<@CapabilityEntity> |
✅ [AppEntity]
|
🕓 | planned — recursive codegen |
Legend: ✅ supported · 🕓 planned (roadmap) ·
1 androidx.appfunctions 1.0.0-alpha08 rejects arbitrary enums — Kapability bridges the gap by surfacing an enum as its String name on Android (a real AppEnum on iOS), so it works on both.
2 AppFunctions models numeric collections as primitive arrays (IntArray, DoubleArray, BooleanArray), not List<T>.
3 Native platform support expected but not yet verified against alpha08.
The Android column reflects androidx.appfunctions 1.0.0-alpha08.
| Version | |
|---|---|
| Kotlin | 2.2.20 (KSP2) |
| Android Gradle Plugin |
8.11+, compileSdk 36 (Android 16 AppFunctions) |
| SKIE | latest (you apply it — for the iOS suspend → async bridge) |
| Xcode | 16+ (iOS App Intents build) |
samples/notes is a full KMP app that consumes the published SDK from a separate
Gradle build (proving the external-consumer path) and runs on both an Android 16+ emulator
(adb shell cmd app_function execute-app-function) and the iOS simulator (App Intents test).
Date, non-String lists (via primitive-array mapping), nested @CapabilityEntity + List<CustomObject>
Provider; register once in commonMain)manifest.json + a verifyKapability drift-detection taskKapability uses Conventional Commits + release-please. Commit / squash-merge titles drive the next version:
| Prefix | Bump | Example |
|---|---|---|
feat: |
minor (0.1.0 → 0.2.0) |
feat: support List<T> parameters |
fix: |
patch (0.1.0 → 0.1.1) |
fix: correct nullable decoding |
feat!: / BREAKING CHANGE:
|
major (→ 1.0.0) |
feat!: rename @Capability.description |
docs: chore: refactor: test: ci: build:
|
none (changelog only) | docs: expand README |
On merge to main, release-please maintains a release PR (version + CHANGELOG.md). Merging it
tags and publishes to Maven Central + the Gradle Plugin Portal automatically.
Copyright 2026 Piyush Sinha
Licensed under the Apache License, Version 2.0 — see LICENSE.
Android (Gemini) and iOS (Siri / Apple Intelligence) are converging on the same idea: apps expose structured, self-describing functions that on-device AI agents can discover and invoke. Android calls it AppFunctions; Apple calls it App Intents. The two APIs are conceptually identical — annotated function + typed params + description + entity types — but completely incompatible in implementation. So a KMP team today writes and maintains two parallel capability layers.
Kapability collapses that into one. A Gradle plugin + KSP processor + thin runtime that turns a single
@Capability declaration in commonMain into native glue for both platforms at build time.
[!NOTE] Status: early / pre-release. Proven end-to-end on both platforms; while pre-1.0 the public API may still change. (Current version: the Maven Central badge above.)
commonMain, native on Android and iOS.@AppFunction wrapper, @AppFunctionSerializable types, the Swift AppIntent + AppEntity, and the dispatch bridge are all generated.androidx.appfunctions compiler and Xcode's App Intents extraction; no reimplemented metadata formats.@Capability(platforms = [ANDROID]) for platform-specific capabilities.plugins { id("dev.kotforge.kapability") }.Artifacts are on Maven Central and the plugin is on the Gradle Plugin Portal, so the default repositories are enough:
// settings.gradle.kts
dependencyResolutionManagement {
repositories {
google()
mavenCentral()
}
}Your KMP module (where capabilities live):
// shared/build.gradle.kts
plugins {
kotlin("multiplatform")
id("com.android.library")
id("co.touchlab.skie") // required: Kotlin suspend -> Swift async bridge
id("dev.kotforge.kapability") version "0.2.0"
}Your Android app module:
// androidApp/build.gradle.kts
plugins {
id("com.android.application")
kotlin("android")
id("dev.kotforge.kapability") version "0.2.0"
}
kapability {
capabilityProject.set(project(":shared"))
}That's it — the plugin adds the kapability-annotations / kapability-runtime dependencies, wires the
KSP processor + the androidx.appfunctions two-pass, and manages the generated sources for you.
Write this once, in commonMain:
import dev.kotforge.kapability.annotations.*
@CapabilityEntity(description = "A note in the app")
data class Note(
@CapabilityId val id: String,
val title: String,
val content: String,
val priority: Int,
)
class NoteCapabilities(private val repo: NoteRepository) {
@Capability(description = "Create a note with the given title, content and priority")
suspend fun createNote(
@CapabilityParam(description = "Title of the note") title: String,
@CapabilityParam(description = "Body content of the note") content: String,
@CapabilityParam(description = "Priority from 1 (low) to 5 (high)") priority: Int,
): Note = repo.create(title, content, priority)
}…and Kapability generates, at build time:
| Platform | Generated | Reaches |
|---|---|---|
| 🤖 Android |
@AppFunction wrapper + @AppFunctionSerializable types → androidx.appfunctions
|
Gemini (AppSearch-indexed) |
| 🍎 iOS | Swift struct CreateNoteIntent: AppIntent + AppEntity (via SKIE async) |
Siri / Shortcuts / Spotlight |
Both dispatch through one generated KapabilityRuntime.invoke() entry point into your shared code. Wire
your capability instance once at startup (KapabilityRuntime.install(NoteCapabilities(repo))), and you're done.
commonMain: @Capability
│ Kapability KSP processor
▼
┌──────────────────────────────────────────────┐
│ generated KapabilityRuntime.invoke() (common) │
└───────────────┬────────────────┬──────────────┘
Android glue │ │ iOS glue
@AppFunction │ │ Swift AppIntent
@AppFunctionSerializable │ AppEntity (SKIE async)
▼ ▼
androidx.appfunctions App Intents runtime
(Gemini) (Siri · Shortcuts · Spotlight)
| Artifact | Purpose |
|---|---|
dev.kotforge:kapability-annotations |
Public API — @Capability, @CapabilityEntity, @CapabilityParam, @CapabilityId, Platform
|
dev.kotforge:kapability-runtime |
The invoke() bridge types + error model |
dev.kotforge:kapability-processor |
KSP2 processor (code generation) |
dev.kotforge.kapability (Gradle plugin)
|
Wires everything into your build |
What each on-device platform supports natively, and what Kapability carries today — as
parameters and @CapabilityEntity properties, returning a @CapabilityEntity. Values travel
across a JSON bridge; anything Kapability doesn't support fails the build with a clear error.
| Type | Android AppFunctions | iOS App Intents | Kapability | How Kapability carries it |
|---|---|---|---|---|
String · Int · Double · Boolean
|
✅ | ✅ | ✅ | JSON scalar → native type on both sides |
Long · Float
|
✅ | ✅ | 🕓 | planned |
| Enum | ❌ 1 | ✅ AppEnum
|
✅ | enum name as a JSON string → Swift AppEnum on iOS, String on the Android surface |
List<String> |
✅ | ✅ [String]
|
✅ | JSON array → List<String> / [String]
|
List<Int> · List<Double> · List<Boolean>
|
✅ [T]
|
🕓 | planned — needs IntArray/… mapping on Android |
|
Nullable (T?) |
✅ | ✅ optional | ✅ | JSON null / absent → nullable type on both sides |
Date |
✅ java.time.*
|
✅ Date
|
🕓 | planned — needs kotlinx-datetime + mapping |
@CapabilityEntity (return type) |
✅ @AppFunctionSerializable
|
✅ AppEntity
|
✅ | generated @AppFunctionSerializable / AppEntity, one level deep |
Nested @CapabilityEntity
|
✅ 3 | ✅ | 🕓 | planned — recursive codegen |
List<@CapabilityEntity> |
✅ [AppEntity]
|
🕓 | planned — recursive codegen |
Legend: ✅ supported · 🕓 planned (roadmap) ·
1 androidx.appfunctions 1.0.0-alpha08 rejects arbitrary enums — Kapability bridges the gap by surfacing an enum as its String name on Android (a real AppEnum on iOS), so it works on both.
2 AppFunctions models numeric collections as primitive arrays (IntArray, DoubleArray, BooleanArray), not List<T>.
3 Native platform support expected but not yet verified against alpha08.
The Android column reflects androidx.appfunctions 1.0.0-alpha08.
| Version | |
|---|---|
| Kotlin | 2.2.20 (KSP2) |
| Android Gradle Plugin |
8.11+, compileSdk 36 (Android 16 AppFunctions) |
| SKIE | latest (you apply it — for the iOS suspend → async bridge) |
| Xcode | 16+ (iOS App Intents build) |
samples/notes is a full KMP app that consumes the published SDK from a separate
Gradle build (proving the external-consumer path) and runs on both an Android 16+ emulator
(adb shell cmd app_function execute-app-function) and the iOS simulator (App Intents test).
Date, non-String lists (via primitive-array mapping), nested @CapabilityEntity + List<CustomObject>
Provider; register once in commonMain)manifest.json + a verifyKapability drift-detection taskKapability uses Conventional Commits + release-please. Commit / squash-merge titles drive the next version:
| Prefix | Bump | Example |
|---|---|---|
feat: |
minor (0.1.0 → 0.2.0) |
feat: support List<T> parameters |
fix: |
patch (0.1.0 → 0.1.1) |
fix: correct nullable decoding |
feat!: / BREAKING CHANGE:
|
major (→ 1.0.0) |
feat!: rename @Capability.description |
docs: chore: refactor: test: ci: build:
|
none (changelog only) | docs: expand README |
On merge to main, release-please maintains a release PR (version + CHANGELOG.md). Merging it
tags and publishes to Maven Central + the Gradle Plugin Portal automatically.
Copyright 2026 Piyush Sinha
Licensed under the Apache License, Version 2.0 — see LICENSE.