wireai-rn
v0.2.6
Published
Wire your AI agent to native mobile UI. Open-source React Native SDK for generative UI — render interactive native components from LLM responses.
Maintainers
Readme
wireai-rn
Wire your AI agent to native mobile UI.
Open-source React Native SDK for generative UI — render interactive native components from LLM responses. No custom parsers. No prompt engineering. Works with Ollama, LM Studio, OpenAI, A2A agents, or any HTTP agent endpoint.
Created by Malik Chohra. Sponsored by AI Mobile Launcher and CasaInnov.
// Your agent returns JSON. Wire RN renders it as native components, validated.
<WireAIProvider llm={config} components={defaultComponents}>
<ChatScreen />
</WireAIProvider>Contents
- The Problem
- Quick Start
- How It Works
- Install
- Step-by-Step Guide
- Composition (nested components)
- Streaming
- Built-in Components
- LLM Provider Reference
- API Reference
- Security
- Acceptable use and the EU AI Act
- Design Tokens
- Sponsors
- Contributing
- License
Quick Start
npm install wireai-rn zodimport { WireAIProvider } from "wireai-rn";
import { defaultComponents } from "wireai-rn/components";
const config = { provider: "openai" as const, model: "gpt-4o-mini", apiKey: "..." };
export default function App() {
return (
<WireAIProvider llm={config} components={defaultComponents}>
<ChatScreen />
</WireAIProvider>
);
}That is the whole setup. Your agent returns { action, component, props }, Wire RN validates the props against the component's Zod schema, and renders a native React Native component. No WebView, no HTML, no hand-written parser. The full chat screen is in Step 3.
The Problem
AI agents speak text. Mobile users expect native UI.
Your agent works — it answers questions, follows instructions, produces useful output. But in React Native you have three bad options: a text chat that feels like 2018, a WebView wrapper that feels cheap, or a custom UI that takes months.
WireAI fills this gap. Register your components with a description and a Zod schema. The LLM picks which one to show. WireAI validates the props and renders it natively.
How It Works
flowchart TD
A([User sends a message]) --> B[useWireAIThread]
B --> C["Build context\n(trim history to budget,\nprepend system prompt)"]
C --> D{LLM Adapter}
D --> |Ollama| E1[Local model]
D --> |LM Studio| E2[Local model]
D --> |OpenAI| E3[Cloud API]
D --> |Webhook| E4[Your backend]
E1 & E2 & E3 & E4 --> F["Raw JSON string\n{ action, component, props }"]
F --> G[validateLLMResponse]
G --> H{Props valid?}
H --> |Yes| I[ComponentRenderer]
H --> |No| J["Turn dropped:\nuseWireAIThread sets error"]
I --> K([Native component renders])FallbackMessage is not on this path. It renders per node inside ComponentRenderer, when a node's props fail their schema or the tree runs past depth 8, and from ComponentErrorBoundary when a mounted component throws. A response that came through validateLLMResponse has already had both of those checks applied to every node, so on the main path above an invalid response surfaces as error and the turn is dropped instead.
How the LLM Chooses a Component
WireAI auto-generates a system prompt from your registered components. The LLM reads it and decides which component fits the conversation.
sequenceDiagram
participant App
participant WireAI
participant LLM
App->>WireAI: <WireAIProvider components={[MoodSelector, ActionCard, ...]}>
WireAI->>WireAI: Build system prompt from registry
Note over WireAI: "You have these components:\n- MoodSelector: use when...\n- ActionCard: use when..."
App->>WireAI: sendMessage("I feel tired today")
WireAI->>LLM: [system prompt] + [history] + [user message]
LLM-->>WireAI: { "action": "render", "component": "MoodSelector", "props": { ... } }
WireAI->>WireAI: Validate props with Zod schema
WireAI-->>App: <MoodSelector question="..." options={[...]} />Install
npm install wireai-rn zod
# or
yarn add wireai-rn zodPeer dependencies:
{
"react": ">=18.0.0",
"react-native": ">=0.73.0",
"zod": ">=3.22.0"
}Zod v3 only. Zod v4 has breaking API changes.
Step-by-Step Guide
Step 1 — Pick your LLM provider
flowchart LR
Dev(["🧑💻 You are..."])
Dev --> A{Environment}
A --> |Local dev, no API cost| B[Ollama]
A --> |Local dev, GUI preferred| C[LM Studio]
A --> |Best quality, cloud| D[OpenAI]
A --> |Production app| E[Webhook / Your backend]
B --> B1["provider: 'ollama'\nbaseUrl: 'http://localhost:11434'\nmodel: 'llama3'"]
C --> C1["provider: 'lmstudio'\nbaseUrl: 'http://localhost:1234'\nmodel: 'llama-3-8b-instruct'"]
D --> D1["provider: 'openai'\nmodel: 'gpt-4o-mini'\napiKey: '...' ← dev only!"]
E --> E1["provider: 'webhook'\nbaseUrl: 'https://api.yourapp.com/ai'\nmodel: 'gpt-4o'"]| Provider | When to use | Cold start | Cost | |---|---|---|---| | Ollama | Local development, privacy | ~2s | Free | | LM Studio | Local development, GUI | ~2s | Free | | OpenAI | Best quality + zero setup | <1s | Pay per token | | Webhook | Production (server holds keys) | <1s | Your backend |
Step 2 — Wrap your app
import { WireAIProvider } from "wireai-rn";
import { defaultComponents } from "wireai-rn/components"; // 11 built-in components
const LLM_CONFIG = {
provider: "ollama" as const,
baseUrl: "http://localhost:11434",
model: "llama3",
};
export default function App() {
return (
<WireAIProvider llm={LLM_CONFIG} components={defaultComponents}>
<ChatScreen />
</WireAIProvider>
);
}WireAIProvider does three things on mount:
- Registers all your components into an in-memory registry
- Builds the LLM system prompt from the registry (auto-generated, no manual prompt writing needed)
- Pings your LLM endpoint in
__DEV__to confirm connectivity
Step 3 — Build a chat screen
import {
useWireAIThread,
useWireAIInput,
useWireAIAction,
ComponentRenderer,
LoadingState,
} from "wireai-rn";
import type { Message } from "wireai-rn";
import {
FlatList,
KeyboardAvoidingView,
TextInput,
Text,
TouchableOpacity,
View,
} from "react-native";
import { useRef, useEffect } from "react";
export function ChatScreen() {
const { messages, isLoading, error, sendMessage, abort } = useWireAIThread();
const { inputText, setInputText, handleSubmit } = useWireAIInput(sendMessage);
const createCallbacks = useWireAIAction(sendMessage);
const listRef = useRef<FlatList>(null);
// Auto-scroll to newest message
useEffect(() => {
if (messages.length) {
setTimeout(() => listRef.current?.scrollToEnd({ animated: true }), 100);
}
}, [messages.length]);
const renderItem = ({ item }: { item: Message }) => {
if (item.role === "user") {
return (
<View style={{ alignSelf: "flex-end", padding: 8, backgroundColor: "#6C47FF", borderRadius: 12, margin: 4 }}>
<Text style={{ color: "#fff" }}>{item.content}</Text>
</View>
);
}
if (item.role === "assistant" && item.response) {
return (
<ComponentRenderer
messageId={item.id}
response={item.response}
callbackOverrides={createCallbacks(item.id)}
isStreaming={item.isStreaming ?? false}
/>
);
}
return null;
};
return (
<KeyboardAvoidingView style={{ flex: 1 }} behavior="padding">
<FlatList
ref={listRef}
data={messages}
keyExtractor={(m) => m.id}
renderItem={renderItem}
contentContainerStyle={{ padding: 16 }}
/>
{isLoading && <LoadingState />}
{error && <Text style={{ color: "red", padding: 8 }}>{error}</Text>}
<View style={{ flexDirection: "row", padding: 8, gap: 8 }}>
<TextInput
style={{ flex: 1, borderWidth: 1, borderRadius: 8, padding: 8 }}
value={inputText}
onChangeText={setInputText}
onSubmitEditing={handleSubmit}
placeholder="Type a message..."
returnKeyType="send"
/>
<TouchableOpacity
onPress={isLoading ? abort : handleSubmit}
style={{ padding: 8, backgroundColor: "#6C47FF", borderRadius: 8 }}
>
<Text style={{ color: "#fff" }}>{isLoading ? "Stop" : "Send"}</Text>
</TouchableOpacity>
</View>
</KeyboardAvoidingView>
);
}Hooks at a glance:
flowchart LR
T[useWireAIThread] --> |messages, isLoading, error| UI
T --> |sendMessage, abort| UI
I[useWireAIInput] --> |inputText, setInputText, handleSubmit| UI
A[useWireAIAction] --> |createCallbacks| CR[ComponentRenderer]
UI --> CRStep 4 — Register a custom component
Custom components give the LLM new capabilities specific to your app.
flowchart TD
S1["1. Define Zod schema\n(what props can the LLM send?)"]
S2["2. Write the React component\n(what does it render?)"]
S3["3. Write the description\n(when should the LLM use it?)"]
S4["4. Export as WireAIComponent\n(name + schema + component + description)"]
S5["5. Pass to WireAIProvider\ncomponents={[MyComponent, ...defaultComponents]}"]
S1 --> S2 --> S3 --> S4 --> S5Full example:
import React, { useCallback, useState } from "react";
import { Pressable, StyleSheet, Text, View } from "react-native";
import { z } from "zod";
import { colors, spacing, radii, textStyles } from "wireai-rn";
import type { WireAIComponent } from "wireai-rn";
// 1. Schema — every field needs .describe() so the LLM knows what to fill in
const schema = z.object({
question: z.string().describe("The mood check-in question, e.g. 'How are you feeling right now?'"),
options: z.array(z.string()).describe("4-6 mood labels, warm and non-clinical"),
});
// 2. React component
const MoodSelectorView: React.FC<z.infer<typeof schema> & { onSelect?: (v: string) => void }> =
React.memo(({ question, options, onSelect }) => {
const [selected, setSelected] = useState<string | null>(null);
const handlePress = useCallback((opt: string) => {
if (selected) return; // one-shot: lock after first tap
setSelected(opt);
onSelect?.(opt);
}, [selected, onSelect]);
return (
<View style={styles.card}>
<Text style={styles.question}>{question}</Text>
{options.map((opt) => (
<Pressable
key={opt}
style={[styles.chip, selected === opt && styles.chipSelected]}
onPress={() => handlePress(opt)}
disabled={!!selected}
>
<Text style={[styles.chipText, selected === opt && styles.chipTextSelected]}>
{opt}
</Text>
</Pressable>
))}
</View>
);
});
// 3 + 4. Export definition — description is a routing instruction for the LLM
export const MoodSelector: WireAIComponent<typeof schema> = {
name: "MoodSelector",
description:
"Use when checking the user's current mood or emotional state at the start of a session. " +
"Provide 4-6 warm, non-clinical mood labels as options. " +
"Do NOT use for yes/no questions — use ConfirmPrompt instead.",
component: MoodSelectorView,
propsSchema: schema,
};
const styles = StyleSheet.create({
card: { padding: spacing.md, backgroundColor: colors.backgroundSecondary, borderRadius: radii.lg, gap: spacing.sm },
question: { ...textStyles.h4, color: colors.text },
chip: { padding: spacing.sm, borderRadius: radii.full, borderWidth: 1, borderColor: colors.border, alignItems: "center" },
chipSelected: { backgroundColor: colors.primary, borderColor: colors.primary },
chipText: { color: colors.textSecondary },
chipTextSelected: { color: "#fff", fontWeight: "600" },
});Then pass it to the provider alongside the built-ins:
<WireAIProvider components={[MoodSelector, ...defaultComponents]}>Component description writing rules:
| Do | Don't |
|---|---|
| "Use when the user needs to pick a mood" | "A mood picker card" |
| "Do NOT use for yes/no — use ConfirmPrompt" | (no negative routing) |
| "Provide 4-6 options, warm and non-clinical" | (no output guidelines) |
| Reference other components by name | Reference your app's business logic |
Step 5 — Add persistence (optional)
Without persistence, conversations reset on every app restart. WireAI supports any storage backend through the StorageBackend interface.
flowchart TD
subgraph "On App Start"
A1[Load config from SecureStore] --> A2[Load history from MMKV]
A2 --> A3["<WireAIProvider initialMessages={history}>"]
end
subgraph "During Session"
B1[User sends message] --> B2[LLM responds]
B2 --> B3["onThreadUpdate(messages)"]
B3 --> B4[Save to MMKV]
end
subgraph "Config Change"
C1[User updates API key] --> C2["saveConfig(newConfig)"]
C2 --> C3[Saved to SecureStore]
endInstall storage libraries:
# API key + LLM config → iOS Keychain / Android Keystore
npx expo install expo-secure-store
# Conversation history → high-performance key-value store
yarn add react-native-mmkvCreate storage backends:
// storage/secureStorageBackend.ts
import * as SecureStore from "expo-secure-store";
import type { StorageBackend } from "wireai-rn";
export const secureStorageBackend: StorageBackend = {
getItem: (key) => SecureStore.getItemAsync(key),
setItem: (key, value) => SecureStore.setItemAsync(key, value),
deleteItem: (key) => SecureStore.deleteItemAsync(key),
};// storage/mmkvHistoryStorage.ts
import { MMKV } from "react-native-mmkv";
import type { StorageBackend } from "wireai-rn";
const store = new MMKV({ id: "chat-history" });
export const mmkvHistoryStorage: StorageBackend = {
getItem: async (key) => store.getString(key) ?? null,
setItem: async (key, value) => store.set(key, value),
deleteItem: async (key) => store.delete(key),
};Wire it all together:
import { useLLMConfigStorage, WireAIProvider } from "wireai-rn";
import type { Message } from "wireai-rn";
import { useCallback, useEffect, useState } from "react";
import { secureStorageBackend } from "./storage/secureStorageBackend";
import { mmkvHistoryStorage } from "./storage/mmkvHistoryStorage";
const DEFAULT_CONFIG = { provider: "openai" as const, model: "gpt-4o-mini" };
const HISTORY_KEY = "chat_history_v1";
export function AppRoot() {
// Config — persisted in SecureStore
const { config, isLoaded, saveConfig } = useLLMConfigStorage(
secureStorageBackend,
DEFAULT_CONFIG
);
// History — loaded from MMKV on first mount
const [initialMessages, setInitialMessages] = useState<Message[]>([]);
const [historyLoaded, setHistoryLoaded] = useState(false);
useEffect(() => {
mmkvHistoryStorage.getItem(HISTORY_KEY).then((raw) => {
if (raw) {
try { setInitialMessages(JSON.parse(raw)); } catch {}
}
setHistoryLoaded(true);
});
}, []);
// Stable callback — called with the full message array on every update
const handleThreadUpdate = useCallback((messages: Message[]) => {
mmkvHistoryStorage.setItem(HISTORY_KEY, JSON.stringify(messages));
}, []);
if (!isLoaded || !historyLoaded) return null; // wait for storage reads
return (
<WireAIProvider
llm={config}
components={defaultComponents}
initialMessages={initialMessages}
onThreadUpdate={handleThreadUpdate}
>
<ChatScreen />
</WireAIProvider>
);
}Composition — nested components
A container component can declare a children slot in its Zod schema using the exported NodeRefSchema. The renderer recurses into the tree, validates each node against the registered component's schema, and hands the container ready-to-mount React elements (pre-rendered in place of the raw NodeRef[]).
1. Declare a container:
import { NodeRefSchema } from "wireai-rn";
import type { WireAIComponent } from "wireai-rn";
import { z } from "zod";
import React from "react";
import { View, Text, StyleSheet } from "react-native";
const cardSchema = z.object({
title: z.string().describe("Card heading"),
children: z
.array(NodeRefSchema)
.optional()
.describe("Nested child components — text, charts, status cards, etc."),
});
// The SDK pre-renders the children array into ReactNode[] before passing it
// down, so the container just lays them out.
const CardView: React.FC<z.infer<typeof cardSchema>> = ({ title, children }) => (
<View style={styles.card}>
<Text style={styles.title}>{title}</Text>
{children as unknown as React.ReactNode}
</View>
);
export const Card: WireAIComponent<typeof cardSchema> = {
name: "Card",
description:
"A container holding a heading plus a list of nested components. Use when you need to group related child components under a single title.",
component: CardView as React.ComponentType<z.infer<typeof cardSchema> & { messageId: string }>,
propsSchema: cardSchema,
};
const styles = StyleSheet.create({
card: { padding: 16, borderRadius: 12, backgroundColor: "#fff", gap: 8 },
title: { fontSize: 17, fontWeight: "600" },
});2. The LLM emits a tree:
{
"action": "render",
"component": "Card",
"props": {
"title": "Trip Summary",
"children": [
{ "component": "MessageBubble", "props": { "message": "Lisbon, Sept 12–18" } },
{ "component": "StatusCard", "props": { "status": "success", "title": "Booked", "ctaLabel": "What's next?" } }
]
}
}The renderer walks the tree, validates each node's props against its registered schema, and substitutes the children array with ReactNode[] before Card mounts.
Notes:
- Max nesting depth is 8. Beyond that, the offending subtree renders
FallbackMessage. - One bad subtree never kills the whole message — each level is wrapped in
ComponentErrorBoundary, and per-node validation failures degrade to a localized fallback. - For dynamic layouts where the SDK can't auto-locate children, the component receives an injected
renderNode(child)helper that turns a singleNodeRefinto aReactNode.
Streaming — progressive UI as tokens arrive
Adapters that implement chatStream (OpenAIAdapter, OllamaAdapter, LMStudioAdapter and WebhookAdapter, which is every adapter except A2AAdapter) push tokens through an XHR-based stream. The thread hook parses partial JSON on each chunk and publishes the best-effort partial response to a small pub/sub store keyed by messageId. Subscribe with useWireAIStream to render only the bubble that's streaming — the rest of the conversation doesn't re-render.
Render a streaming bubble:
import { ComponentRenderer, LoadingState, useWireAIStream } from "wireai-rn";
import type { Message } from "wireai-rn";
function AssistantBubble({ message }: { message: Message }) {
const stream = useWireAIStream(message.id);
const response = stream?.response ?? message.response;
if (!response) return <LoadingState />;
return (
<ComponentRenderer
messageId={message.id}
response={response}
isStreaming={stream?.isStreaming ?? message.isStreaming ?? false}
/>
);
}Why XHR (not fetch)? React Native's fetch does not expose response.body as a ReadableStream, so true progressive reads are only available through XMLHttpRequest.onprogress. The adapter accumulates xhr.responseText and forwards new bytes to your onChunk callback.
Webhook server contract. When using WebhookAdapter.chatStream, your server must respond with Transfer-Encoding: chunked and stream the assistant's raw JSON text progressively. If your server wraps responses in { content: ... }, either strip the wrapper before streaming or use the non-streaming chat() method.
Behavior during a stream:
- A placeholder assistant
Messageis inserted immediately when you callsendMessage. It carriesisStreaming: trueand an emptycontentuntil the final chunk arrives. - The stream store holds the latest partial. Nodes whose props don't yet pass strict Zod validation silently render
nulluntil the next chunk fills them in (no fallback flicker). - On the final chunk, the strict validator runs against the full buffer. On success, the placeholder is replaced with the finalized message and the stream store entry is cleared. On failure, the placeholder is removed and
erroris set. - Aborting (e.g. user taps Stop) tears down the XHR; no late
onChunkcallbacks fire.
Falls back automatically. A2AAdapter is the only adapter without chatStream, so an A2A turn keeps the one-shot chat() path even with streaming left on. No code change needed in your app to support the mix. If you configure provider: "custom", it resolves to the LM Studio adapter, so it streams like the rest.
No offline or cold-start lane. If the configured provider, server or agent is unreachable, the turn rejects: the assistant placeholder is removed and error is set. That holds for the very first turn as much as any later one. The SDK ships no cached first question, no canned offline flow and no automatic retry. What it gives you is errorKind and retry() on useWireAIThread, so your app can show its own affordance and re-run the last user message on a tap. Seeding initialMessages on WireAIProvider puts your own content on screen before the first turn, but it does not make that turn succeed.
Built-in Components (11)
Import from wireai-rn/components. All are Zod-validated, React.memo-wrapped, and include submitted-state protection (no double-submit).
| Component | Use When | Key Props |
|---|---|---|
| ActionCard | Offering 1–3 next-step CTA buttons | title, actions[{ label, value }] |
| ChipSelectCard | Quick selection from compact labels (moods, tags, activities) | question, options[], multiSelect? |
| ConfirmPrompt | Binary yes/no decision before an action | question, confirmLabel?, cancelLabel? |
| ContentSelectCard | Selecting from items with title + description | title, items[{ title, description, value }] |
| InfoList | Displaying read-only key/value summary data | title, items[{ label, value }] |
| MessageBubble | Standard text chat response | message, sender? |
| NumberStepperCard | Picking a number within a range | label, min, max, step?, defaultValue? |
| SelectionCard | Choosing one option from a list with longer labels | title, options[{ label, value, description? }] |
| StatusCard | Success, error, or informational status display | status, title, message? |
| StepList | Ordered steps or itinerary | title, steps[] |
| TextInputCard | Collecting free-text input (name, goal, note) | label, placeholder?, submitLabel? |
LLM Provider Reference
const config: LocalLLMConfig = {
provider: "ollama" | "lmstudio" | "openai" | "webhook",
baseUrl: "...",
model: "...",
apiKey?: "...", // OpenAI / Webhook auth
timeoutMs?: 30000, // default: 30s
temperature?: 0.7, // default: 0.7
maxTokens?: 1024, // default: 1024
};| Provider | Default base URL | Ping endpoint | Notes |
|---|---|---|---|
| ollama | http://localhost:11434 | GET /api/tags | Physical device: use LAN IP |
| lmstudio | http://localhost:1234 | GET /v1/models | Start server in LM Studio first |
| openai | https://api.openai.com | GET /v1/models | Strips /v1 suffix if present in baseUrl |
| webhook | (required) | skipped | POST / with { messages, model } |
WireAIProvider Props
<WireAIProvider
llm={config} // Required — LLM connection config
components={components} // Required — WireAIComponent[] registry
maxContextMessages={20} // Max messages kept in context (default: 20)
maxContextChars={12000} // Max chars in context (~3k tokens, default: 12000)
systemPromptSuffix="You are..." // Append app-specific instructions to auto-generated prompt
initialMessages={[]} // Pre-populate thread (e.g. from storage)
onMessage={(msg) => {}} // Called with each new message (analytics hook)
onThreadUpdate={(msgs) => {}} // Called with full thread on every update (persistence hook)
>
{children}
</WireAIProvider>Hooks Reference
useWireAIStream(messageId)
const stream = useWireAIStream(messageId);
// stream?.response — latest partial WireAIResponse (or undefined)
// stream?.isStreaming — true until the final chunk arrivesUsed inside per-message bubbles when the adapter supports chatStream. Re-renders are coalesced via requestAnimationFrame so a fast token stream doesn't flood React.
useWireAIThread()
const {
messages, // Message[] — full conversation history
isLoading, // boolean — LLM request in flight
error, // string | null — last error message
errorKind, // "interrupted" | "failed" | null — why the last turn ended unanswered
sendMessage, // (text: string, options?: SendMessageOptions) => void
retry, // () => void — re-run the last user message
reset, // () => void — clear history, return to initial state
abort, // () => void — cancel in-flight request
} = useWireAIThread();errorKind says why the thread is sitting without an answer, and it is null
when the thread is healthy. "failed" means the request errored and error
carries the message. "interrupted" means the app went to background mid-turn
and the request was aborted: nothing failed, error stays null, and the
user's message is still in the thread unanswered. Show a retry affordance and
call retry(). The SDK never resends by itself.
retry() re-runs the last user message without appending a second copy of it.
It is a no-op while a send is in flight, and a no-op unless the newest message
is an unanswered user message, so calling it twice cannot double-send.
useWireAIInput(sendMessage)
const {
inputText, // string — controlled input value
setInputText, // Dispatch<SetStateAction<string>>
handleSubmit, // () => void — trims + sends + clears input
} = useWireAIInput(sendMessage);useWireAIAction(sendMessage)
Returns a factory that creates callback props for interactive components.
const createCallbacks = useWireAIAction(sendMessage);
// In renderItem:
<ComponentRenderer
messageId={item.id}
response={item.response}
callbackOverrides={createCallbacks(item.id)}
// ^ injects onSelect, onConfirm, onSubmit etc. that send messages back to the thread
/>useLLMConfigStorage(storage, defaultConfig)
const {
config, // LocalLLMConfig — current resolved config
isLoaded, // boolean — false until storage read completes
saveConfig, // (config: LocalLLMConfig) => Promise<void>
clearConfig, // () => Promise<void> — resets to defaultConfig
} = useLLMConfigStorage(storage, defaultConfig);| Param | Type | Description |
|---|---|---|
| storage | StorageBackend | Any { getItem, setItem, deleteItem } implementation |
| defaultConfig | LocalLLMConfig | Fallback config if nothing is stored yet |
Context Budget
WireAI automatically trims old messages when the conversation grows:
maxContextMessages = 20 ← drop oldest messages beyond this count
maxContextChars = 12000 ← drop oldest messages beyond this char limitThe system prompt is always prepended fresh and is never trimmed. A dev warning fires at 80% utilization so you can tune before hitting limits.
<WireAIProvider
maxContextMessages={30} // increase for long sessions
maxContextChars={20000} // increase for GPT-4o (128k context)
>Security
flowchart LR
subgraph "❌ Never do this in production"
A["apiKey in app bundle"] --> B["Extractable from .ipa / .apk"]
end
subgraph "✅ Production pattern"
C["User authenticates to your server"] --> D["Your server holds the LLM API key"]
D --> E["provider: 'webhook'\nbaseUrl: 'https://api.yourapp.com/ai'"]
end- Local LLMs (Ollama, LM Studio): no API key needed — safe for production if the model runs on the device or a private server
- Cloud LLMs in dev:
apiKeyis fine in__DEV__mode, but WireAI logs a warning - Cloud LLMs in production: always use
WebhookAdapterwith a server-side proxy
Acceptable use and the EU AI Act
Position as of 2026-08-06, against Regulation (EU) 2024/1689 as amended by Regulation (EU) 2026/1744. This is our reading, not legal advice, and a review by a qualified lawyer is pending.
This system is not intended to be put into service as, or changed into, a high-risk AI system. Do not build Wire-powered surfaces into an Annex III high-risk area: biometrics, critical infrastructure, education and vocational training, employment, access to essential services and benefits, law enforcement, migration, or administration of justice. Two Article 5(1) prohibitions have been live since 2 February 2025: no subliminal, manipulative or deceptive techniques that materially distort behaviour, and no exploiting vulnerabilities due to age, disability, or a specific social or economic situation.
The app that installs this SDK is the deployer of the surface it ships. Wire runs no model and hosts no service here, so you pick the model, the prompt and the flow.
On Article 50(1), applicable since 2 August 2026 with no legacy grace period: our reading is that generated UI made of cards, buttons and form fields is not a conversational agent, and a user tapping through it is not being led to believe there is a human on the other end. If you build a chat-like or free-text-responding surface instead, that reading stops and the disclosure that the user is interacting with an AI system is your app's to place. This SDK ships MessageBubble and a ChatScreen boilerplate, so that path is a short one from here.
On Article 50(2): the text a user sees comes from a model you chose, and provider-level marking of a model's output sits upstream with that model's provider. For the render layer, we rely on the carve-out in the article itself, which "shall not apply to the extent the AI systems perform an assistive function for standard editing or do not substantially alter the input data provided by the deployer or the semantics thereof."
The full position, including Article 50(3), Article 25 and what the MIT licence changes, is in EU-AI-ACT.md.
Design Tokens
Use these in your custom components to stay visually consistent with the built-ins:
import { colors, spacing, radii, textStyles } from "wireai-rn";
// Colors
colors.primary // #6C47FF — violet
colors.text // #0D0D14 — near-black
colors.textSecondary // #6B7280 — muted
colors.backgroundSecondary // surface cards
colors.border // subtle stroke
// Spacing
spacing.xs // 4
spacing.sm // 8
spacing.md // 16
spacing.lg // 24
spacing.xl // 32
// Radii
radii.sm // 6
radii.md // 10
radii.lg // 16
radii.full // 999 (pill)
// Text styles (spread into StyleSheet)
textStyles.h3 // 20px semibold
textStyles.h4 // 17px semibold
textStyles.body // 15px regular
textStyles.small // 13px regularContributing
git clone [email protected]:chohra-med/wireai-rn.git
cd wireai-rn/packages/core
yarn install
yarn build # ESM + CJS + .d.ts (tsup)
yarn test # jest — run the test suites
yarn typecheckSee CONTRIBUTING.md for pull request guidelines.
Sponsors
Wire RN is open source and free. Its development is backed by:
- AI Mobile Launcher — the AI-native React Native boilerplate. Ship an AI mobile app with local + cloud LLMs, generative UI, and a paywall already wired.
- CasaInnov — AI-native mobile product studio. Done-for-you AI mobile builds and fractional CTO work.
Want your product here? Open an issue or reach out at getwireai.com.
License
MIT — see LICENSE for details.
Created by Malik Chohra — React Native engineer, building AI-native mobile. Website · Newsletter · X / @malik_chohra
Sponsored by AI Mobile Launcher and CasaInnov.
