Skip to main content

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.

The Assistant SDK ships a default chat surface — MetabindAssistantView on iOS, the Compose / React equivalents on Android and web — 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), use the SDK’s lower-level API to drive your own surface.

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

The default chat surface is built on a small public API on the MetabindAssistant object:
MemberPurpose
assistant.send(text)Submit a user message; returns when the response stream completes.
assistant.conversationObservable conversation state. messages holds the running turns.
assistant.cancel()Cancel the in-flight turn.
assistant.isProcessingObservable boolean — true while a turn is streaming.
Tool result UI is rendered through the BindJS native renderer (BindJSView on iOS, BindJSCompose on Android, <BindJSRenderer> on web). Each message’s tool results carry the BindJS spec; you hand it to the renderer.

iOS example

import SwiftUI
import MetabindAssistant
import BindJS

struct CustomAssistant: View {
  @ObservedObject var 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") {
            let text = input
            input = ""
            Task { await assistant.send(text) }
          }
        }
      }
      .padding()
    }
  }
}

struct MessageView: View {
  let message: ConversationMessage

  var body: some View {
    VStack(alignment: .leading) {
      if let text = message.text { Text(text) }

      ForEach(message.toolResults) { result in
        BindJSView(spec: result.ui)
      }
    }
  }
}
MetabindAssistant is an ObservableObject — bind it with @ObservedObject or @StateObject. Tool results render through BindJSView, the SwiftUI renderer for BindJS specs.

Android example

@Composable
fun CustomAssistant(assistant: MetabindAssistant) {
  val messages by assistant.conversation.messages.collectAsState()
  val isProcessing by assistant.isProcessing.collectAsState()
  val scope = rememberCoroutineScope()
  var input by remember { mutableStateOf("") }

  Column {
    LazyColumn(modifier = Modifier.weight(1f)) {
      items(messages) { message -> MessageView(message) }
    }

    Row {
      TextField(value = input, onValueChange = { input = it })
      if (isProcessing) {
        Button(onClick = { assistant.cancel() }) { Text("Stop") }
      } else {
        Button(onClick = {
          val text = input
          input = ""
          scope.launch { assistant.send(text) }
        }) { Text("Send") }
      }
    }
  }
}

@Composable
fun MessageView(message: ConversationMessage) {
  Column {
    message.text?.let { Text(it) }
    message.toolResults.forEach { result ->
      BindJSCompose(spec = result.ui)
    }
  }
}
BindJSCompose is the Compose renderer for BindJS specs.

Web example

import { useAssistant } from "@metabind/assistant-sdk/react";
import { BindJSRenderer } from "@bindjs/renderer";

export function CustomAssistant({ assistant }) {
  const { messages, isProcessing, send, cancel } = useAssistant(assistant);
  const [input, setInput] = useState("");

  return (
    <div className="my-workspace">
      <div className="my-conversation">
        {messages.map((message) => (
          <Message key={message.id} message={message} />
        ))}
      </div>

      <div className="compose">
        <input value={input} onChange={(e) => setInput(e.target.value)} />
        {isProcessing ? (
          <button onClick={cancel}>Stop</button>
        ) : (
          <button onClick={() => { send(input); setInput(""); }}>Send</button>
        )}
      </div>
    </div>
  );
}

function Message({ message }) {
  return (
    <>
      {message.text && <p>{message.text}</p>}
      {message.toolResults?.map((r) => (
        <BindJSRenderer key={r.id} spec={r.ui} />
      ))}
    </>
  );
}
useAssistant subscribes to the assistant’s observable state. Tool result UI renders through BindJSRenderer, the React renderer.

What the default UI does that you’ll need to handle

If you replace the default UI, replicate (or skip) these as needed:
FeatureWhy
Streaming token renderingShow partial responses as they arrive — read message.text reactively.
Tool call status”Calling product_search…” while the tool runs.
Error statesTool failures, network errors, cancellations.
Cancel buttonLet users abort a long response — call assistant.cancel().
Auto-scrollKeep the latest content in view.
AccessibilityDynamic type, screen reader support — VoiceOver / TalkBack.
Empty / loading / error statesCover 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.
  • Server-side replay. Persist messages to your backend (the SDK does not ship a persistence adapter today); rehydrate on next session by re-creating the assistant and replaying messages.

Persistence

Conversation state is held in memory by the Conversation observable on the assistant. If your app needs persistence across launches or reloads, serialize assistant.conversation.messages to your platform’s storage (Core Data, SwiftData, Room, IndexedDB, your backend) and rehydrate on next launch.

iOS SDK

Default chat surface and configuration.

Android SDK

Default chat surface and configuration.

LLM provider configuration

Agent proxy vs. BYOK for the LLM call.

Assistant SDK overview

Conceptual: when to embed and what’s in the box.