> ## Documentation Index
> Fetch the complete documentation index at: https://docs.metabind.ai/llms.txt
> Use this file to discover all available pages before exploring further.

# Custom host UI

> Replace the default Assistant SDK chat surface with your own UI

On iOS and Android, the Assistant SDK ships the same pair: `MetabindAssistant`, which runs the conversation, and `MetabindAssistantView`, a default chat surface that handles most cases. When you need full control over the UI (a different layout, a non-chat interaction model, custom branding beyond what theming covers), keep `MetabindAssistant` and drive your own surface from its public API.

## Why go custom

Most teams ship the default UI for the first release and customize later. Cases where custom is right from day one:

* Your assistant lives inside a non-chat surface — a sidebar, an inline panel, a voice-first interaction.
* You want UI primitives that don't fit a chat metaphor — e.g., a multi-pane workspace where the assistant is one component.
* Your design system mandates components that diverge significantly from the SDK's chat default.
* You're building an agent UI (open-ended task execution) rather than a chat UI.

If your case is "the chat looks fine but I want a different color scheme," start with theming the default — it covers far more than it appears to.

## What the lower-level API gives you

On iOS, the default chat surface is built on a small public API on the `MetabindAssistant` object:

| Member | Purpose |
| - | - |
| `assistant.send(text)` | Submit a user message. Returns immediately; the response streams into `conversation` while `isProcessing` is true. Ignored while a turn is in flight. |
| `assistant.conversation` | Observable conversation state. `messages` holds the running turns. |
| `assistant.cancel()` | Cancel the in-flight turn. |
| `assistant.isProcessing` | Observable boolean — true while a turn is streaming. |

Tool result UI is rendered through the BindJS native renderer. On iOS, each tool call arrives in `conversation.messages` as a `.tool(MCPAppSession)` message; `MCPAppView(session:)` fetches the tool's UI resource and renders it as native SwiftUI. On Android, each tool call arrives in `messages` as a `TOOL` message; pass `toolUIContent[message.id]` to `MetabindToolView`, which renders it as Compose, or in a WebView for an HTML resource.

## iOS example

```swift theme={null}
import SwiftUI
import MetabindAI

struct CustomAssistant: View {
  let assistant: MetabindAssistant

  @State private var input: String = ""

  var body: some View {
    VStack {
      ScrollView {
        ForEach(assistant.conversation.messages) { message in
          MessageView(message: message)
        }
      }

      HStack {
        TextField("Ask something…", text: $input)

        if assistant.isProcessing {
          Button("Stop") { assistant.cancel() }
        } else {
          Button("Send") {
            assistant.send(input)
            input = ""
          }
        }
      }
      .padding()
    }
  }
}

struct MessageView: View {
  let message: Message

  var body: some View {
    switch message {
    case .user(_, let text):
      Text(text)
        .frame(maxWidth: .infinity, alignment: .trailing)
    case .assistant(_, let text):
      Text(text)
        .frame(maxWidth: .infinity, alignment: .leading)
    case .tool(let session):
      MCPAppView(session: session)
    }
  }
}
```

`MetabindAssistant` and its `conversation` use the Observation framework (`@Observable`), so SwiftUI updates the view whenever `conversation.messages` or `isProcessing` changes. Pass the assistant as a plain property, as above, or hold it in `@State` if the view creates it. `MCPAppView` comes from `MCPAppsHost`, which `MetabindAI` re-exports.

## Android example

On Android, `MetabindAssistant` exposes `StateFlow`s: `messages`, `isLoading`, `error`, and `toolUIContent`. Collect them, call `send(text)` and `cancel()`, and render each `TOOL` message's UI with `MetabindToolView`:

```kotlin theme={null}
import ai.metabind.ai.MessageRole
import ai.metabind.ai.MetabindAssistant
import ai.metabind.ai.MetabindToolView

@Composable
fun CustomAssistant(assistant: MetabindAssistant) {
    val messages by assistant.messages.collectAsState()
    val isLoading by assistant.isLoading.collectAsState()
    val toolUIContent by assistant.toolUIContent.collectAsState()
    var input by remember { mutableStateOf("") }

    Column {
        LazyColumn(modifier = Modifier.weight(1f)) {
            items(messages, key = { it.id }) { message ->
                if (message.role == MessageRole.TOOL) {
                    toolUIContent[message.id]?.let { content ->
                        MetabindToolView(
                            assistant = assistant,
                            toolName = message.toolName ?: "",
                            content = content,
                        )
                    }
                } else {
                    Text(message.content)
                }
            }
        }

        Row(modifier = Modifier.padding(16.dp)) {
            TextField(
                value = input,
                onValueChange = { input = it },
                modifier = Modifier.weight(1f),
                placeholder = { Text("Ask something…") },
            )
            if (isLoading) {
                Button(onClick = { assistant.cancel() }) { Text("Stop") }
            } else {
                Button(onClick = {
                    assistant.send(input)
                    input = ""
                }) { Text("Send") }
            }
        }
    }
}
```

`toolUIContent` is keyed by tool call ID, the same ID as the `TOOL` message, and fills in once the tool's UI resource loads. The [finance demo](https://github.com/metabindai/metabind-android/tree/main/samples/finance-demo) is a complete custom Android UI built this way. The [Android SDK](/guides/assistant-sdk/android-sdk) guide lists the full API.

## What the default UI does that you'll need to handle

If you replace the default UI, replicate (or skip) these as needed:

| Feature | Why |
| - | - |
| Streaming token rendering | Show partial responses as they arrive — an `.assistant` message's text updates in place as tokens stream in. |
| Tool call status | "Calling product\_search…" while the tool runs. |
| Error states | Tool failures, network errors, cancellations. |
| Cancel button | Let users abort a long response — call `assistant.cancel()`. |
| Auto-scroll | Keep the latest content in view. |
| Accessibility | Dynamic type, screen reader support — VoiceOver / TalkBack. |
| Empty / loading / error states | Cover the conversation lifecycle. |

## Conversation state observability

The conversation state is the source of truth. Patterns:

* **Read-only views.** Render the conversation in a different surface (e.g., a summary sidebar) by subscribing to the same state.
* **Multi-pane layouts.** A chat pane on one side, a tool result detail pane on the other — both subscribed to the same state, both displaying different slices.

## Related

<CardGroup cols={2}>
  <Card title="iOS SDK" icon="apple" href="/guides/assistant-sdk/ios-sdk">
    Default chat surface and configuration.
  </Card>

  <Card title="Android SDK" icon="android" href="/guides/assistant-sdk/android-sdk">
    Default chat surface and configuration.
  </Card>

  <Card title="LLM provider configuration" icon="brain" href="/guides/assistant-sdk/llm-provider-configuration">
    Agent proxy vs. BYOK for the LLM call.
  </Card>

  <Card title="Assistant SDK overview" icon="rocket" href="/guides/getting-started/embed-an-assistant">
    Conceptual: when to embed and what's in the box.
  </Card>
</CardGroup>


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.