Skip to content

Quick start: pure client

In this mode the agent runs elsewhere, on your server, a remote A2A agent, or a coding agent over ACP, and the app renders its conversation. You need ai-elements-ui and the backend for your protocol (ai-elements-core for AI SDK and AG-UI).

App setup

Declare network access in src/main/AndroidManifest.xml:

<uses-permission android:name="android.permission.INTERNET" />

For the local reference server only, allow HTTP in the debug manifest (src/debug/AndroidManifest.xml):

<manifest xmlns:android="http://schemas.android.com/apk/res/android">
    <application android:usesCleartextTraffic="true" />
</manifest>

Use HTTPS for a production endpoint. An Android emulator reaches your computer as 10.0.2.2; localhost on the emulator refers to the emulator itself. If connecting fails, check the server's /health, bind address and firewall before changing your backend.

One composable

Use these imports inside a Compose activity's setContent:

import dev.ai.elements.ui.theme.AiElementsTheme
import dev.ai.elements.ui.chat.Chat
import dev.ai.elements.ui.chat.rememberChat
import dev.ai.elements.core.protocol.agui.AgUiBackend

Chat wires the conversation, tools, approvals, sub-agents, plans and the prompt input to a ChatController:

AiElementsTheme {
    Chat(rememberChat { approver -> AgUiBackend("https://agents.example.com/api/agui", approver = approver) })
}

rememberChat gives the backend an approver: the app's human in the loop, which shows approvals and forms in the conversation.

In a ViewModel

In an app, keep the controller in a ViewModel so that the conversation survives configuration changes. Compose the pieces yourself when you need more control:

class ChatViewModel : ViewModel() {
    // An AG-UI agent (Pydantic AI, LangGraph, CrewAI, Mastra…). Tools that need approval pause
    // the run as an AG-UI interrupt; the UI shows a Confirmation and resumes it.
    val chat = ChatController(backend = { approver ->
        AgUiBackend("https://agents.example.com/api/agui", approver = approver)
    }, scope = viewModelScope)
}

@Composable
fun ChatScreen(vm: ChatViewModel) {
    val state by vm.chat.state.collectAsStateWithLifecycle()
    var input by rememberSaveable { mutableStateOf("") }
    AiElementsTheme {
        Column(Modifier.fillMaxSize().imePadding()) {
            Conversation(
                state = state,
                modifier = Modifier.weight(1f),
                onRegenerate = vm.chat::regenerate,
                onToolApproval = vm.chat::respondToApproval,
                onToolDecision = vm.chat::respondToApproval,   // deny with a reason, edit then approve
                onInputResponse = vm.chat::respondToInput,     // forms the agent asks the user to fill
                onSelectVersion = vm.chat::selectVersion,
                onRestoreCheckpoint = vm.chat::restoreCheckpoint,
            )
            PromptInput(
                value = input,
                onValueChange = { input = it },
                onSubmit = { if (vm.chat.send(input)) input = "" },
                onStop = vm.chat::stop,
                busy = state.isBusy,
                allowQueue = true,
            )
        }
    }
}

Sub-agents, plans, shared state and activities from the server render on their own. For example, a delegate_task call or an AG-UI SUBAGENT_* run becomes a Subagent card with the nested run.

Choose a backend

Every backend is a ChatBackend. Pick the one your agent speaks:

// AI SDK 5 / 6 UI Message Stream (any `toUIMessageStreamResponse()` server)
{ approver -> UiMessageStreamBackend("https://agents.example.com/api/chat", approver = approver) },
// AG-UI 1.x (Pydantic AI, LangGraph, CrewAI, Mastra, …)
{ approver -> AgUiBackend("https://agents.example.com/api/agui", approver = approver) },
// A2A 1.0 remote agent (ai-elements-a2a)
{ _ -> A2aBackend(A2aAgent("https://agents.example.com")) },
// Agent Client Protocol coding agent (ai-elements-acp)
{ approver -> AcpBackend(AcpAgent.webSocket("wss://dev-box.example.com/acp"), approver) },
// A model API on the device, with the built-in agent loop
{ approver ->
    ProviderProfile("openai", "OpenAI", ProviderKind.OPENAI_RESPONSES, "https://api.openai.com/v1", "gpt-5.5")
        .createBackend(apiKey = "…", approver = approver)
},

Protocols compares them and explains each one, including how the server side should be built.

Try it against the reference server

The repository includes a reference server, a Pydantic AI agent served over every protocol, with a scripted model that needs no API key:

cd server && uv sync
uv run uvicorn main:app --host 0.0.0.0 --port 8788

Point a backend at http://10.0.2.2:8788/api/agui (the Android emulator's alias for your computer), or use the LAN address of your computer on a device.

The samples/pure-client app in the repository is a complete, minimal app for this mode.

Lifecycle and errors

rememberChat is scoped to the composition and is suitable for a prototype. Use a ViewModel for configuration changes. A ViewModel does not survive process death: persist the messages in your app's store and supply them as initialMessages when creating a new controller.

Call the controller from the main thread and use viewModelScope. Stop cancels the current turn; transport errors become ChatState.error. Retry requests a new turn; it does not reconnect to the middle of the previous stream. See storage and replay.