Skip to main content
Arcanflows

Mobile Integration

Integrate AI agents into iOS, Android, Flutter, and React Native apps

Integrate Arcanflows AI agents into your iOS, Android, Flutter, or React Native app using the Embed REST API — no iframe or browser required.

Overview

The Arcanflows Embed API is a pure REST + SSE interface that lets any HTTP client send messages to a published agent and receive responses. It uses an Embed API Key for authentication, making it ideal for mobile apps where cookie/session-based auth is impractical.

What you can do:

  • Send user messages and receive AI responses (sync or streaming)
  • Maintain conversation history across app sessions
  • Retrieve past conversations by session
  • Fetch agent configuration (name, avatar, welcome message)

Prerequisites

  1. A published agent — create one in the Arcanflows console
  2. An Embed API Key — generate one from your agent's Embed Settings tab
  3. Your base URL — e.g. https://dev6.arcanflows.com or your production domain

Important: When creating the API key, set Allowed Domains to empty or * so requests from mobile apps (which have no Origin header) are not blocked.

Authentication

Every request to the public chat endpoints must include your Embed API Key. Three methods are supported:

MethodHeader / ParamExample
Bearer tokenAuthorization: Bearer <key>Authorization: Bearer sk_pub_abc123
Custom headerX-API-Key: <key>X-API-Key: sk_pub_abc123
Query parameter?api_key=<key>/chat?api_key=sk_pub_abc123

Security: Never hard-code the API key in client-side source that ships to app stores. Use your backend to proxy requests, or store the key in a secure enclave / Keychain / EncryptedSharedPreferences.

API Endpoints

Replace {agent_id} with your agent's UUID.

Send Message (Synchronous)

POST /api/v1/public/agents/{agent_id}/chat

Request Body:

json
{
  "session_id": "unique-session-id",
  "message": "Hello, I need help with my order",
  "conversation_id": "optional-uuid",
  "visitor_id": "optional-visitor-id",
  "visitor_name": "John Doe",
  "visitor_email": "[email protected]"
}
FieldRequiredDescription
session_idYesUnique session identifier. Persist this across app launches.
messageYesThe user's message text.
conversation_idNoUUID of an existing conversation. Omit to start a new one.
visitor_idNoYour internal user ID.
visitor_nameNoDisplay name for analytics.
visitor_emailNoVisitor email for analytics.

Response (200):

json
{
  "conversation_id": "550e8400-e29b-41d4-a716-446655440000",
  "user_message": {
    "id": "msg-uuid-1",
    "role": "user",
    "content": "Hello, I need help with my order",
    "created_at": "2026-02-25T10:30:00Z"
  },
  "assistant_reply": {
    "id": "msg-uuid-2",
    "role": "assistant",
    "content": "Hi! I'd be happy to help with your order. Could you share your order number?",
    "input_tokens": 45,
    "output_tokens": 120,
    "created_at": "2026-02-25T10:30:01Z"
  }
}

Send Message (Streaming via SSE)

POST /api/v1/public/agents/{agent_id}/chat/stream

Same request body as the synchronous endpoint. The response is a Server-Sent Events stream:

data: {"type":"metadata","conversation_id":"uuid","sources":["doc1.pdf"]}
data: {"type":"chunk","delta":"Hi! ","thinking":null,"model":"gpt-4o"}
data: {"type":"chunk","delta":"I'd be happy to help.","thinking":null}
data: {"type":"done","content":"Hi! I'd be happy to help."}
Event typeDescription
metadataFirst event — contains conversation_id and RAG sources
chunkToken-by-token streaming. Append delta to your UI buffer.
doneFinal event with the complete content.
errorError event if something goes wrong.

Get Conversations by Session

GET /api/v1/public/agents/{agent_id}/conversations?session_id={session_id}

Returns the conversation linked to the given session, including all messages.

Get Specific Conversation

GET /api/v1/public/agents/{agent_id}/conversations/{conversation_id}

Returns a single conversation with all its messages.

Get Agent Configuration

GET /api/v1/public/agents/{agent_id}/embed

No authentication required. Returns agent display info for your UI:

json
{
  "id": "agent-uuid",
  "name": "Customer Support Bot",
  "avatar_url": "https://example.com/avatar.png",
  "embed_settings": {
    "enabled": true,
    "theme": "light",
    "welcome_message": "Hello! How can I help you today?"
  }
}

Swift (iOS)

Synchronous Chat

swift
import Foundation

struct ChatRequest: Codable {
    let sessionId: String
    let message: String
    let conversationId: String?

    enum CodingKeys: String, CodingKey {
        case sessionId = "session_id"
        case message
        case conversationId = "conversation_id"
    }
}

struct ChatMessage: Codable {
    let id: String
    let role: String
    let content: String
}

struct ChatResponse: Codable {
    let conversationId: String
    let assistantReply: ChatMessage

    enum CodingKeys: String, CodingKey {
        case conversationId = "conversation_id"
        case assistantReply = "assistant_reply"
    }
}

class ArcanflowsClient {
    let baseURL: String
    let agentId: String
    let apiKey: String

    init(baseURL: String, agentId: String, apiKey: String) {
        self.baseURL = baseURL
        self.agentId = agentId
        self.apiKey = apiKey
    }

    func sendMessage(
        sessionId: String,
        message: String,
        conversationId: String? = nil
    ) async throws -> ChatResponse {
        let url = URL(string: "\(baseURL)/api/v1/public/agents/\(agentId)/chat")!
        var request = URLRequest(url: url)
        request.httpMethod = "POST"
        request.setValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization")
        request.setValue("application/json", forHTTPHeaderField: "Content-Type")

        let body = ChatRequest(
            sessionId: sessionId,
            message: message,
            conversationId: conversationId
        )
        request.httpBody = try JSONEncoder().encode(body)

        let (data, response) = try await URLSession.shared.data(for: request)
        guard let http = response as? HTTPURLResponse,
              http.statusCode == 200 else {
            throw URLError(.badServerResponse)
        }
        return try JSONDecoder().decode(ChatResponse.self, from: data)
    }
}

SSE Streaming (iOS)

swift
func streamMessage(
    sessionId: String,
    message: String,
    onChunk: @escaping (String) -> Void,
    onDone: @escaping (String) -> Void
) async throws {
    let url = URL(string: "\(baseURL)/api/v1/public/agents/\(agentId)/chat/stream")!
    var request = URLRequest(url: url)
    request.httpMethod = "POST"
    request.setValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization")
    request.setValue("application/json", forHTTPHeaderField: "Content-Type")

    let body = ChatRequest(sessionId: sessionId, message: message, conversationId: nil)
    request.httpBody = try JSONEncoder().encode(body)

    let (bytes, _) = try await URLSession.shared.bytes(for: request)
    for try await line in bytes.lines {
        guard line.hasPrefix("data: ") else { continue }
        let jsonStr = String(line.dropFirst(6))
        guard let data = jsonStr.data(using: .utf8),
              let event = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
              let type = event["type"] as? String else { continue }

        switch type {
        case "chunk":
            if let delta = event["delta"] as? String { onChunk(delta) }
        case "done":
            if let content = event["content"] as? String { onDone(content) }
        default: break
        }
    }
}

Kotlin (Android)

Synchronous Chat

kotlin
import okhttp3.*
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.RequestBody.Companion.toRequestBody
import com.google.gson.Gson
import com.google.gson.annotations.SerializedName

data class ChatRequest(
    @SerializedName("session_id") val sessionId: String,
    val message: String,
    @SerializedName("conversation_id") val conversationId: String? = null
)

data class ChatMessage(
    val id: String,
    val role: String,
    val content: String
)

data class ChatResponse(
    @SerializedName("conversation_id") val conversationId: String,
    @SerializedName("assistant_reply") val assistantReply: ChatMessage
)

class ArcanflowsClient(
    private val baseURL: String,
    private val agentId: String,
    private val apiKey: String
) {
    private val client = OkHttpClient()
    private val gson = Gson()
    private val json = "application/json".toMediaType()

    fun sendMessage(
        sessionId: String,
        message: String,
        conversationId: String? = null
    ): ChatResponse {
        val body = gson.toJson(ChatRequest(sessionId, message, conversationId))
            .toRequestBody(json)

        val request = Request.Builder()
            .url("$baseURL/api/v1/public/agents/$agentId/chat")
            .post(body)
            .addHeader("Authorization", "Bearer $apiKey")
            .build()

        client.newCall(request).execute().use { response ->
            if (!response.isSuccessful) throw Exception("HTTP ${response.code}")
            return gson.fromJson(response.body?.string(), ChatResponse::class.java)
        }
    }
}

SSE Streaming (Android)

kotlin
fun streamMessage(
    sessionId: String,
    message: String,
    onChunk: (String) -> Unit,
    onDone: (String) -> Unit
) {
    val body = gson.toJson(ChatRequest(sessionId, message))
        .toRequestBody(json)

    val request = Request.Builder()
        .url("$baseURL/api/v1/public/agents/$agentId/chat/stream")
        .post(body)
        .addHeader("Authorization", "Bearer $apiKey")
        .build()

    client.newCall(request).execute().use { response ->
        val source = response.body?.source() ?: return
        while (!source.exhausted()) {
            val line = source.readUtf8Line() ?: break
            if (!line.startsWith("data: ")) continue
            val event = gson.fromJson(line.removePrefix("data: "), Map::class.java)
            when (event["type"]) {
                "chunk" -> (event["delta"] as? String)?.let { onChunk(it) }
                "done"  -> (event["content"] as? String)?.let { onDone(it) }
            }
        }
    }
}

Flutter / Dart

Synchronous Chat

dart
import 'dart:convert';
import 'package:http/http.dart' as http;

class ArcanflowsClient {
  final String baseURL;
  final String agentId;
  final String apiKey;

  ArcanflowsClient({
    required this.baseURL,
    required this.agentId,
    required this.apiKey,
  });

  Future<Map<String, dynamic>> sendMessage({
    required String sessionId,
    required String message,
    String? conversationId,
  }) async {
    final uri = Uri.parse('$baseURL/api/v1/public/agents/$agentId/chat');
    final response = await http.post(
      uri,
      headers: {
        'Authorization': 'Bearer $apiKey',
        'Content-Type': 'application/json',
      },
      body: jsonEncode({
        'session_id': sessionId,
        'message': message,
        if (conversationId != null) 'conversation_id': conversationId,
      }),
    );

    if (response.statusCode != 200) {
      throw Exception('HTTP ${response.statusCode}: ${response.body}');
    }
    return jsonDecode(response.body);
  }
}

SSE Streaming (Flutter)

dart
Stream<String> streamMessage({
  required String sessionId,
  required String message,
}) async* {
  final request = http.Request(
    'POST',
    Uri.parse('$baseURL/api/v1/public/agents/$agentId/chat/stream'),
  );
  request.headers['Authorization'] = 'Bearer $apiKey';
  request.headers['Content-Type'] = 'application/json';
  request.body = jsonEncode({
    'session_id': sessionId,
    'message': message,
  });

  final response = await http.Client().send(request);
  await for (final chunk in response.stream.transform(utf8.decoder)) {
    for (final line in chunk.split('\n')) {
      if (!line.startsWith('data: ')) continue;
      final data = jsonDecode(line.substring(6));
      if (data['type'] == 'chunk') {
        yield data['delta'] as String;
      }
    }
  }
}

React Native

Synchronous Chat

javascript
const BASE_URL = 'https://dev6.arcanflows.com';
const AGENT_ID = 'your-agent-id';
const API_KEY = 'sk_pub_your_key';

async function sendMessage(sessionId, message, conversationId = null) {
  const response = await fetch(
    \`\${BASE_URL}/api/v1/public/agents/\${AGENT_ID}/chat\`,
    {
      method: 'POST',
      headers: {
        'Authorization': \`Bearer \${API_KEY}\`,
        'Content-Type': 'application/json',
      },
      body: JSON.stringify({
        session_id: sessionId,
        message,
        ...(conversationId && { conversation_id: conversationId }),
      }),
    }
  );

  if (!response.ok) {
    throw new Error(\`HTTP \${response.status}\`);
  }
  return response.json();
}

SSE Streaming (React Native)

javascript
async function streamMessage(sessionId, message, onChunk, onDone) {
  const response = await fetch(
    \`\${BASE_URL}/api/v1/public/agents/\${AGENT_ID}/chat/stream\`,
    {
      method: 'POST',
      headers: {
        'Authorization': \`Bearer \${API_KEY}\`,
        'Content-Type': 'application/json',
      },
      body: JSON.stringify({ session_id: sessionId, message }),
    }
  );

  const reader = response.body.getReader();
  const decoder = new TextDecoder();
  let buffer = '';

  while (true) {
    const { done, value } = await reader.read();
    if (done) break;

    buffer += decoder.decode(value, { stream: true });
    const lines = buffer.split('\n');
    buffer = lines.pop() || '';

    for (const line of lines) {
      if (!line.startsWith('data: ')) continue;
      const event = JSON.parse(line.slice(6));
      if (event.type === 'chunk') onChunk(event.delta);
      if (event.type === 'done') onDone(event.content);
    }
  }
}

Session Management

Persist the session_id so returning users resume their conversation:

PlatformStorage
iOSUserDefaults or Keychain
AndroidSharedPreferences or EncryptedSharedPreferences
Fluttershared_preferences package
React NativeAsyncStorage

Also persist the conversation_id from the first response so subsequent messages continue the same thread.

Error Handling

StatusMeaningAction
401Missing API keyAdd the key via header or query param
403Invalid API key, or domain restrictionCheck key is correct, active, and domains allow mobile
429Rate limit exceededBack off and retry with exponential delay
500Server-side errorRetry after a short delay

Recommended retry strategy: wait 1s, 2s, 4s, then give up after 4 attempts.

Domain Restrictions

Mobile apps typically don't send an Origin header. When creating your API key, set Allowed Domains to empty (no restrictions) or * to explicitly allow all origins.

Next Steps