diff --git a/apps/mobile/package.json b/apps/mobile/package.json
index 3899cef255..75cea06b0a 100644
--- a/apps/mobile/package.json
+++ b/apps/mobile/package.json
@@ -23,6 +23,9 @@
"dependencies": {
"@expo-google-fonts/jetbrains-mono": "^0.4.1",
"@expo/react-native-action-sheet": "^4.1.1",
+ "@kilocode/event-service": "workspace:*",
+ "@kilocode/kilo-chat": "workspace:*",
+ "@kilocode/notifications": "workspace:*",
"@kilocode/trpc": "workspace:*",
"@react-native-community/netinfo": "11.5.2",
"@rn-primitives/portal": "^1.3.0",
diff --git a/apps/mobile/src/app/(app)/_layout.tsx b/apps/mobile/src/app/(app)/_layout.tsx
index 60998ebc61..d47c26095a 100644
--- a/apps/mobile/src/app/(app)/_layout.tsx
+++ b/apps/mobile/src/app/(app)/_layout.tsx
@@ -1,75 +1,78 @@
import { Stack } from 'expo-router';
+import { KiloChatProvider } from '@/components/kilo-chat/kilo-chat-provider';
import { useThemeColors } from '@/lib/hooks/use-theme-colors';
export default function AppLayout() {
const colors = useThemeColors();
return (
-
-
-
-
+
-
-
-
-
-
-
-
-
+ >
+
+
+
+
+
+
+
+
+
+
+
+
);
}
diff --git a/apps/mobile/src/components/kilo-chat/hooks/use-current-user-id.ts b/apps/mobile/src/components/kilo-chat/hooks/use-current-user-id.ts
new file mode 100644
index 0000000000..6c1e850f4c
--- /dev/null
+++ b/apps/mobile/src/components/kilo-chat/hooks/use-current-user-id.ts
@@ -0,0 +1,46 @@
+import { useEffect, useState } from 'react';
+
+import { useKiloChatTokenGetter } from './use-kilo-chat-token';
+
+/**
+ * Decodes the `kiloUserId` claim from the kilo-chat JWT and returns it as the
+ * current user's ID. Returns `null` while loading or if the token cannot be
+ * decoded. The token is minted by `generateApiToken`, which writes the user id
+ * as `kiloUserId` (not the standard JWT `sub` claim).
+ */
+export function useCurrentUserId(): string | null {
+ const getToken = useKiloChatTokenGetter();
+ const [userId, setUserId] = useState(null);
+
+ useEffect(() => {
+ let cancelled = false;
+
+ async function fetchUserId() {
+ try {
+ const token = await getToken();
+ if (cancelled) {
+ return;
+ }
+ const parts = token.split('.');
+ if (parts.length < 2 || !parts[1]) {
+ return;
+ }
+ const payload = parts[1];
+ const decoded = atob(payload.replaceAll('-', '+').replaceAll('_', '/'));
+ const parsed = JSON.parse(decoded) as Record;
+ const kiloUserId = typeof parsed.kiloUserId === 'string' ? parsed.kiloUserId : null;
+ setUserId(kiloUserId);
+ } catch {
+ // Leave userId as null on failure
+ }
+ }
+
+ void fetchUserId();
+
+ return () => {
+ cancelled = true;
+ };
+ }, [getToken]);
+
+ return userId;
+}
diff --git a/apps/mobile/src/components/kilo-chat/hooks/use-kilo-chat-client.ts b/apps/mobile/src/components/kilo-chat/hooks/use-kilo-chat-client.ts
new file mode 100644
index 0000000000..32e7402aa3
--- /dev/null
+++ b/apps/mobile/src/components/kilo-chat/hooks/use-kilo-chat-client.ts
@@ -0,0 +1,20 @@
+import { type EventServiceClient } from '@kilocode/event-service';
+import { type KiloChatClient } from '@kilocode/kilo-chat';
+
+import { useKiloChatContext } from '../kilo-chat-provider';
+
+/**
+ * Returns the {@link KiloChatClient} instance from the nearest
+ * {@link KiloChatProvider}. Throws if called outside a provider.
+ */
+export function useKiloChatClient(): KiloChatClient {
+ return useKiloChatContext().kiloChatClient;
+}
+
+/**
+ * Returns the {@link EventServiceClient} instance from the nearest
+ * {@link KiloChatProvider}. Throws if called outside a provider.
+ */
+export function useEventServiceClient(): EventServiceClient {
+ return useKiloChatContext().eventService;
+}
diff --git a/apps/mobile/src/components/kilo-chat/hooks/use-kilo-chat-token.ts b/apps/mobile/src/components/kilo-chat/hooks/use-kilo-chat-token.ts
new file mode 100644
index 0000000000..bec3cbaef0
--- /dev/null
+++ b/apps/mobile/src/components/kilo-chat/hooks/use-kilo-chat-token.ts
@@ -0,0 +1,61 @@
+import * as SecureStore from 'expo-secure-store';
+import { useCallback } from 'react';
+
+import { AUTH_TOKEN_KEY } from '@/lib/storage-keys';
+import { trpcClient } from '@/lib/trpc';
+
+type TokenCache = {
+ authToken: string;
+ token: string;
+ expiresAtMs: number;
+};
+
+// Module-level cache keyed on the user's auth token, so a sign-out followed by
+// a different sign-in within the JWT window doesn't return the previous user's
+// token. The in-flight ref is keyed the same way for the same reason.
+let cache: TokenCache | null = null;
+let inFlight: { authToken: string; promise: Promise } | null = null;
+
+/**
+ * Returns a stable getter function that fetches a kilo-chat JWT, caching it
+ * until 60 seconds before expiry. Concurrent callers share a single in-flight
+ * fetch via a module-level dedup ref.
+ *
+ * The auth token is read from SecureStore at call time (matching `trpcClient`)
+ * rather than captured from `useAuth()`, so a getter constructed before auth
+ * has loaded — or before the user signs in — picks up the correct token on
+ * its next call instead of permanently capturing `undefined`.
+ */
+export function useKiloChatTokenGetter(): () => Promise {
+ return useCallback(async () => {
+ const authToken = await SecureStore.getItemAsync(AUTH_TOKEN_KEY);
+ if (!authToken) {
+ throw new Error('Cannot fetch kilo-chat token: not authenticated');
+ }
+
+ if (cache && cache.authToken === authToken && cache.expiresAtMs - Date.now() > 60_000) {
+ return cache.token;
+ }
+
+ if (inFlight && inFlight.authToken === authToken) {
+ return inFlight.promise;
+ }
+
+ const slot = { authToken, promise: fetchAndCacheToken(authToken) };
+ inFlight = slot;
+ try {
+ return await slot.promise;
+ } finally {
+ // Only clear the slot if a concurrent caller hasn't replaced it.
+ if (inFlight === slot) {
+ inFlight = null;
+ }
+ }
+ }, []);
+}
+
+async function fetchAndCacheToken(authToken: string): Promise {
+ const { token, expiresAt } = await trpcClient.kiloChat.getToken.query();
+ cache = { authToken, token, expiresAtMs: new Date(expiresAt).getTime() };
+ return token;
+}
diff --git a/apps/mobile/src/components/kilo-chat/kilo-chat-provider.tsx b/apps/mobile/src/components/kilo-chat/kilo-chat-provider.tsx
new file mode 100644
index 0000000000..f255f96a2b
--- /dev/null
+++ b/apps/mobile/src/components/kilo-chat/kilo-chat-provider.tsx
@@ -0,0 +1,53 @@
+import { createContext, useContext, useEffect, useState } from 'react';
+
+import { EventServiceClient } from '@kilocode/event-service';
+import { KiloChatClient } from '@kilocode/kilo-chat';
+
+import { EVENT_SERVICE_URL, KILO_CHAT_URL } from '@/lib/config';
+
+import { useKiloChatTokenGetter } from './hooks/use-kilo-chat-token';
+
+type KiloChatContextValue = {
+ eventService: EventServiceClient;
+ kiloChatClient: KiloChatClient;
+};
+
+export const KiloChatContext = createContext(null);
+
+export function useKiloChatContext(): KiloChatContextValue {
+ const ctx = useContext(KiloChatContext);
+ if (!ctx) {
+ throw new Error('useKiloChatContext must be used within a KiloChatProvider');
+ }
+ return ctx;
+}
+
+type KiloChatProviderProps = {
+ children: React.ReactNode;
+};
+
+export function KiloChatProvider({ children }: KiloChatProviderProps) {
+ const getToken = useKiloChatTokenGetter();
+
+ const [value] = useState(() => {
+ const eventService = new EventServiceClient({
+ url: EVENT_SERVICE_URL,
+ getToken,
+ });
+ const kiloChatClient = new KiloChatClient({
+ eventService,
+ baseUrl: KILO_CHAT_URL,
+ getToken,
+ });
+ return { eventService, kiloChatClient };
+ });
+
+ useEffect(() => {
+ void value.eventService.connect();
+ return () => {
+ value.eventService.disconnect();
+ };
+ }, [value]);
+
+ return {children};
+}
diff --git a/apps/mobile/src/lib/config.ts b/apps/mobile/src/lib/config.ts
index b90c113990..be543127f8 100644
--- a/apps/mobile/src/lib/config.ts
+++ b/apps/mobile/src/lib/config.ts
@@ -18,3 +18,6 @@ export const APPSFLYER_APP_ID: string = required('appsFlyerAppId');
export const CLOUD_AGENT_WS_URL: string = required('cloudAgentWsUrl');
export const SESSION_INGEST_WS_URL: string = required('sessionIngestWsUrl');
+
+export const KILO_CHAT_URL: string = required('kiloChatUrl');
+export const EVENT_SERVICE_URL: string = required('eventServiceUrl');
diff --git a/apps/mobile/src/lib/env-keys.js b/apps/mobile/src/lib/env-keys.js
index 3ec4d72001..3200f2ecda 100644
--- a/apps/mobile/src/lib/env-keys.js
+++ b/apps/mobile/src/lib/env-keys.js
@@ -7,4 +7,6 @@ export const ENV_KEYS = {
sessionIngestWsUrl: 'SESSION_INGEST_WS_URL',
appsFlyerDevKey: 'APPSFLYER_DEV_KEY',
appsFlyerAppId: 'APPSFLYER_APP_ID',
+ kiloChatUrl: 'EXPO_PUBLIC_KILO_CHAT_URL',
+ eventServiceUrl: 'EXPO_PUBLIC_EVENT_SERVICE_URL',
};
diff --git a/packages/kilo-chat/src/utils.ts b/packages/kilo-chat/src/utils.ts
index 741140875d..56f794469d 100644
--- a/packages/kilo-chat/src/utils.ts
+++ b/packages/kilo-chat/src/utils.ts
@@ -18,7 +18,7 @@ export type ConversationCursor = { t: number; c: string };
function base64urlEncode(bytes: Uint8Array): string {
let binary = '';
- for (let i = 0; i < bytes.length; i++) binary += String.fromCharCode(bytes[i]);
+ for (const byte of bytes) binary += String.fromCharCode(byte);
return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
}
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 3ea3a33947..3a7e7b96b6 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -146,6 +146,15 @@ importers:
'@expo/react-native-action-sheet':
specifier: ^4.1.1
version: 4.1.1(@types/react@19.2.14)(react@19.2.0)
+ '@kilocode/event-service':
+ specifier: workspace:*
+ version: link:../../packages/event-service
+ '@kilocode/kilo-chat':
+ specifier: workspace:*
+ version: link:../../packages/kilo-chat
+ '@kilocode/notifications':
+ specifier: workspace:*
+ version: link:../../packages/notifications
'@kilocode/trpc':
specifier: workspace:*
version: link:../../packages/trpc