Graine AI

Kotlin Multiplatform

The in-app agent in a Kotlin Multiplatform app — one API in shared code for Android and iOS, with the chat (its window size, conversations that continue, a new conversation), calls, scheduled calls, several agents, and the chat, the call screen and the voice bar for Compose Multiplatform.

ai.graine:inapp-kmp puts the agent into a Kotlin Multiplatform app from shared code. On each platform Graine's native SDK does the work — the same engine, the same design from the dashboard, the same calls — and this library is one API over both. ai.graine:inapp-kmp-compose adds the SDK's own chat, call screen and voice bar as Compose Multiplatform composables.

Install

// shared/build.gradle.kts
kotlin {
    androidTarget()
    iosArm64(); iosSimulatorArm64(); iosX64()
    sourceSets {
        commonMain.dependencies {
            implementation("ai.graine:inapp-kmp:0.10.3")
            implementation("ai.graine:inapp-kmp-compose:0.10.3")   // Compose Multiplatform UI
        }
    }
}

Kotlin 2.1.20 or newer. Two dashboard steps, the same as every platform: give the app its own agent, and add app:// to its Allowed domains under Embed & Widgets.

Android

Android 7.0 (API 24) and up. The Android SDK comes with the library, and its manifest brings the permissions, the call screen, the call service and a Firebase messaging service of its own. The library finds your application by itself, so configuring from shared code just works.

For calls, add Firebase to your Android app; the SDK registers for pushes itself. With Firebase Cloud Messaging in the app (google-services.json, its Gradle plugin and com.google.firebase:firebase-messaging in the Android target's dependencies), the SDK's own messaging service receives the token and Graine's calls, and the SDK reads the current token when it starts. The SDK does not depend on Firebase: an app without it builds and runs as before.

Already have your own FirebaseMessagingService? It outranks the SDK's and receives every message, so hand Graine's over — two lines:

class Messaging : FirebaseMessagingService() {
    override fun onNewToken(token: String) = Graine.calls.onNewToken(token)
    override fun onMessageReceived(message: RemoteMessage) {
        if (Graine.calls.handlePush(message.data)) return
        // …your own messages
    }
}

Ask for POST_NOTIFICATIONS (Android 13+) during onboarding, and forward permission answers from an activity that shows the voice bar or places calls:

override fun onRequestPermissionsResult(requestCode: Int, permissions: Array<out String>, grantResults: IntArray) {
    if (Graine.onRequestPermissionsResult(requestCode, grantResults)) return
    super.onRequestPermissionsResult(requestCode, permissions, grantResults)
}

iOS

iOS 15 and up. Add Graine's iOS SDK to the app — the library calls it, and never carries a second copy:

  • Swift Package Manager — Xcode → File → Add Package Dependencies…, https://github.com/GraineAI/inapp-ios from 0.10.3, on the app target. Build the shared framework as a static framework (isStatic = true, the Compose Multiplatform default).
  • CocoaPods — in the Podfile: pod 'GraineInApp', :git => 'https://github.com/GraineAI/inapp-ios.git', :tag => '0.10.3'. With Kotlin's CocoaPods plugin and a dynamic framework, declare pod("GraineInApp") { version = "0.10.3"; linkOnly = true } in the shared module instead.

In Info.plist the microphone description, and for calls the Push Notifications capability and the Audio and Voice over IP background modes:

<key>NSMicrophoneUsageDescription</key>
<string>Used to talk to the assistant.</string>
<!-- With GraineInAppRTC (calls on WebRTC): App Store processing requires it, because WebRTC contains camera code -->
<key>NSCameraUsageDescription</key>
<string>Calls are audio only. The camera is never turned on.</string>
<key>UIBackgroundModes</key>
<array><string>audio</string><string>voip</string></array>

Call Graine.calls.enableVoIPPushes() at every launch: iOS wakes a closed app for a call and hands the push over once it has run.

Calls over WebRTC (optional)

Calls — the user calling the agent, and a call Graine rang — carry their audio over a WebSocket unless the app includes each platform's WebRTC module. With it, they carry it over WebRTC: a jitter buffer, loss concealment and a connection that holds up better on mobile data. Nothing in shared code changes.

  • Android — the module beside the Android SDK, at the same version, in the Android app (or androidMain): implementation("ai.graine:inapp-android-rtc:0.10.3"). Its manifest adds ACCESS_NETWORK_STATE (a normal permission: nothing is asked) and its R8 rules ship inside it.
  • iOS — the GraineInAppRTC package (https://github.com/GraineAI/inapp-ios-rtc), at the same version as GraineInApp, its library added to the app target beside GraineInApp; or, with CocoaPods, pod 'GraineInAppRTC', :git => 'https://github.com/GraineAI/inapp-ios-rtc.git', :tag => '0.10.3' beside pod 'GraineInApp', the same way and at the same version. The calls find it by themselves.

A call WebRTC cannot set up continues on the WebSocket inside the same call — no second ring, no error. The agent's In-app call audio setting (Calls & scheduling) — WebRTC (recommended) or Standard — can move every install's calls to the WebSocket from their next start. Over WebRTC the agent's voice arrives at telephone bandwidth today. Size, per phone: about 12 MB installed on an iPhone, about 7.4 MB on a 64-bit Android phone. WebRTC is BSD 3-Clause with Google's patent grant — list it in your app's open-source licences.

Start

Graine.configure(GraineConfig(
    baseUrl = "https://www.graine.ai",
    publishableKey = "pk_live_…",          // Agent → Embed & Widgets
    appVersion = "2.4.0",
))

Once, at launch: Application.onCreate on Android, the app's init on iOS. Every method may be called from any thread; the suspending ones never block the main thread, and every state is a flow that holds the latest value.

Who the user is, and memory

Graine.identify(mapOf("user_id" to user.id, "name" to user.firstName))
Graine.identify(mapOf("user_id" to user.id), memory = false)   // remember nothing about this user
val remembered = Graine.memories()                             // what the agent remembers, newest first
val deleted = Graine.forgetMemory()                            // how many memories were erased
Graine.identify(null)                                          // sign out

The agent's Identity setting names the trait that says who the user is — user_id unless you chose email, phone or a custom trait. Its value goes only to Graine, never into the prompt, and a placeholder value is nobody. A forgetMemory() that succeeds also forgets the conversation kept for the user and clears the chat on screen; one that throws (unavailable, device_limit, network) changes nothing.

What is on the screen

Graine.setScreen(GraineScreen(
    screen = "checkout",
    title = "Payment",
    fields = listOf(GraineField("card", label = "Card", status = GraineFieldStatus.INVALID, error = "Card declined")),
))
Graine.updateField(GraineField("otp", status = GraineFieldStatus.EMPTY))
Graine.clearScreen("checkout")
Graine.track("coupon_applied", mapOf("code" to "WELCOME10"))

Report real values; they are masked on the device on the way out. Graine.setVariables(mapOf("plan" to user.plan)) adds prompt variables from your app's own data, and Graine.currentVariables() returns every variable the agent will receive — the dashboard's defaults, the config's variables, setVariables, then who is signed in, merged in that order. 0.9.0

Let the agent act

Graine.registerAction("open_screen") { args ->
    val screen = args["screen"] as? String ?: return@registerAction GraineActionResult.refused("Which screen?")
    navigator.open(screen)
    GraineActionResult.ok("Opened $screen.")
}

The handler starts on the main thread and answers within six seconds; it may suspend. Arguments arrive as the same plain values on both platforms.

The chat and the voice bar

GraineChat(Modifier.fillMaxSize(), onClose = { navigator.pop() })
 
Box(Modifier.fillMaxSize()) {
    AppContent()
    GraineAgentBar(Modifier.align(Alignment.BottomCenter).fillMaxWidth().height(180.dp))
}

Both are the SDK's own screens, in the brand's design: GraineChat() wraps the platform SDK's chat window, so everything below comes with it. For your own UI, draw from Graine.chat (turns, status, typing, upcomingCalls; open, send, close, retry, newConversation, clear — the screen only — answerWidget(id, data, summary) for a component the agent drew, refreshUpcomingCalls, cancelUpcomingCall(id)), Graine.voice (state, with captionsOn and captions; start, end, setMuted, setCaptionsOn(on), clearCaptions()) and Graine.theme — the brand's design tokens (colours, type, corners, depth, motion, icons, the chat window's and the call screen's), for the device's light or dark appearance, null before the session. 0.9.0

How big the chat opens. The Garage's Chat window → Window size sets it per agent; your app can choose otherwise, for every open or for one. The open's size wins, then the app's, then the dashboard's.

Graine.configure(GraineConfig(baseUrl = "https://www.graine.ai", publishableKey = "pk_live_…", chatSize = GraineChatSize.LARGE))
Graine.chat.open(size = GraineChatSize.FULL)   // this open only, until close()

COMPACT is the window above the voice bar, LARGE a sheet over most of a phone (a panel in a tablet's corner), FULL the whole screen. A voice bar on screen moves to the chat and draws it at that size; moving between the call and the chat keeps it.

Conversations continue. Where the agent remembers its users, a signed-in user who comes back — later today, tomorrow, on their other phone — finds the chat where they left it, and the agent picks up from there. The chat opens with the earlier messages, oldest first, each restored = true, and what was said on an earlier call as channel = GraineChatChannel.VOICE. GraineChat() shows them under day separators, an earlier call as one Voice call row, with no starters and New conversation in its header. A chat of your own lays the turns out the same way:

val rows = GraineChatRows.build(turns)   // GraineChatRow.Day, VoiceCall and Turn, on the device's days
// GraineChatRows.dayLabel(row), callLabel(clock), offersStarters(turns), offersNewConversation(turns)

Graine.chat.newConversation() starts a fresh thread: Graine lets go of the kept conversation, the chat clears and opens again with the welcome, and what the agent remembers is untouched. It throws a GraineException when Graine refuses (device_limit, network…), and the chat is then as it was.

Calls

val callId = Graine.calls.startCall(reason = "card_blocked")
scope.launch { Graine.calls.outcomes.collect { outcome -> report(outcome) } }
 
val call by Graine.calls.state.collectAsState()
if (call.showsCallScreen) GraineCallScreen(Modifier.fillMaxSize())

With the WebRTC modules in the app, Graine.calls.configure(GraineCallsConfig(callTransport = GraineCallTransport.AUTO)) — the default — carries calls over WebRTC (WEBSOCKET keeps the WebSocket, WEBRTC asks for WebRTC wherever it can carry the call), and the call's state says which one ran: transport (WEBRTC or WEBSOCKET) and transportFallbackReason (why a call that tried WebRTC is on the WebSocket). 0.9.0

Each outcome has the call's id, what happened (RINGING, ANSWERED, DECLINED, MISSED, FAILED, ENDED), why, how long a connected call lasted, whether Graine rang it, and which agent it was with (agent, the name it was configured under — on Android null for a ring refused because another call was live). A ring (Graine.calls.incoming) and the call's state carry agent too. With your push credentials uploaded (Workspace settings → In-app calling), Graine rings your users itself: the calls the agent books in a conversation, the ones users book in your app, and the ones booked from the dashboard.

Scheduled calls

val rules = Graine.schedulingRules
val call = Graine.scheduleCall(rules.earliest() + 1.hours, reason = "Follow-up")
val upcoming = Graine.scheduledCalls()
Graine.cancelScheduledCall(call.id)

The agent's rules are checked before anything is sent (scheduling_off, too_soon, too_far), then the server's (outside_window, too_many_pending). Every refusal is a GraineException with a stable code, the server's sentence as its message, and retryAfterSeconds when the server asked to wait.

Several agents

Graine.configure(GraineConfig(baseUrl, publishableKey = "pk_live_…"))                         // the default agent
Graine.configure(GraineConfig(baseUrl, publishableKey = "pk_live_…"), name = "support")       // another
GraineChat(Modifier.fillMaxSize(), agent = "support")
Graine.calls.startCall(reason = "billing", agent = "support")

Each agent has its own session, chat, voice bar (GraineAgentBar(agent = "support")), actions, scheduling and memory (Graine.agent("support")); who the user is, the install id, the memory choice and the screen are shared. Each keeps its own conversation and opens its chat at its own design's size. One conversation at a time across all of them; a call's agent says which one it is with.

Every function and type, with each platform's beside it: the SDK reference and Types.

On this page