Amba

Kitchen-sink Expo tutorial

Step-by-step build of a to-do list Expo app that exercises the whole Amba SDK surface — auth, content, streaks, remote config, and push.

In this tutorial you'll build a real Expo to-do app that exercises every major Amba feature:

  • Email signup and login.
  • Todos stored as content-library items (read and written from the app).
  • A daily "completed a todo" streak.
  • A remote-config feature flag (weekly_goal_count).
  • Push notifications on iOS and Android.

When you're done you'll have a TestFlight- and Play-Internal-ready app that uses every major Amba feature.

1. Prerequisites

  • Node.js 20 or later (node -v). Node 22 also works.
  • Expo CLI — invoked via npx, no global install needed.
  • iOS only: Xcode 15+ for local dev builds, or an Expo account for EAS Build.
  • Android only: Android Studio with an emulator, or a physical device with USB debugging.

You don't need an Apple Developer account to build and run the app, but you will need one ($99/yr) to test iOS push notifications and submit to TestFlight. Skip the iOS push section if you don't have one.

On iOS, push notifications require a development build — Expo Go can't deliver them. Plan to test push on the development build, not in Expo Go.

2. Install the SDK

Start from a fresh Expo project:

npx create-expo-app@latest my-app --template default
cd my-app
npx @layers/amba init

npx @layers/amba init walks you through:

  1. Sign in — opens a browser for login (or reuses cached credentials).
  2. Pick or create a project — choose an existing project or name a new one.
  3. Mint a client key — generates a development client key (prefixed amba_ck_).
  4. Write .env.local — your projectId, clientKey, and apiUrl land in the project root.
  5. Drop agent context — writes AMBA.md and .cursor/rules/amba.mdc so Cursor / Claude Code know how to drive Amba in your project.

Then install the Expo SDK and its peer dependencies:

npx expo install @layers/amba-expo \
  @react-native-async-storage/async-storage \
  expo-notifications expo-device \
  expo-apple-authentication expo-auth-session \
  expo-crypto expo-linking

3. Environment variables

Expo only exposes env vars that start with EXPO_PUBLIC_ to client-side code. Rewrite .env.local so the Amba values use the Expo prefix:

# .env.local
 
# Client-side — embedded in the JS bundle, visible to end users.
EXPO_PUBLIC_AMBA_PROJECT_ID=proj_xxx
EXPO_PUBLIC_AMBA_CLIENT_KEY=amba_ck_xxx
EXPO_PUBLIC_AMBA_API_URL=https://api.amba.dev

There are no server-side env vars required for the mobile app itself. If you later add an edge function or a server that calls the Admin API, it will need un-prefixed developer credentials (AMBA_DEVELOPER_TOKEN=...) — never ship those to the client.

.gitignore in the Expo template already excludes .env*.local. Confirm before committing.

4. Configure the Expo plugin

The @layers/amba-expo config plugin auto-wires push entitlements, Apple Sign In capability, and URL schemes so you don't have to hand-edit Info.plist / AndroidManifest.xml / app.json. Register the plugin and pass any options you want:

// app.json
{
  "expo": {
    "name": "todo-kitchen-sink",
    "slug": "todo-kitchen-sink",
    "plugins": [
      "expo-router",
      [
        "@layers/amba-expo",
        {
          "ios": {
            "pushNotifications": true,
            "urlSchemes": ["todokitchensink"],
            "associatedDomains": ["example.com"]
          },
          "android": {
            "intentFilters": [{ "scheme": "todokitchensink" }]
          }
        }
      ]
    ],
    "ios": {
      "bundleIdentifier": "com.example.todokitchensink",
      "supportsTablet": true
    },
    "android": {
      "package": "com.example.todokitchensink"
    }
  }
}

What the plugin adds for you:

  • iOSaps-environment entitlement (default development, EAS flips to production at build time), applinks: associated domains, and CFBundleURLTypes for deep-link URL schemes. Apple Sign In capability is enabled via the peer expo-apple-authentication plugin.
  • Android — intent filters on .MainActivity for each scheme/host/pathPrefix you pass. autoVerify is added automatically when the scheme is https.

You never need to hand-edit Info.plist or AndroidManifest.xml for basic push + deep-link wiring. Rebuild native after changing app.json:

npx expo prebuild           # writes ios/ + android/ with the plugin output
# or let `expo run:ios` / `eas build` do it implicitly

Pick your own bundleIdentifier / package — you'll need them later for APNs / FCM.

5. Initialize the SDK

Edit (or create) app/_layout.tsx:

import { useEffect, useState } from 'react';
import { Stack } from 'expo-router';
import { View, ActivityIndicator } from 'react-native';
import { Amba } from '@layers/amba-expo';
 
export default function RootLayout() {
  const [ready, setReady] = useState(false);
 
  useEffect(() => {
    Amba.configure({
      projectId: process.env.EXPO_PUBLIC_AMBA_PROJECT_ID!,
      clientKey: process.env.EXPO_PUBLIC_AMBA_CLIENT_KEY!,
      apiUrl: process.env.EXPO_PUBLIC_AMBA_API_URL,
    })
      .catch((err) => {
        // Don't swallow silently in dev — network outages and misconfigured
        // env vars both show up here.
        console.warn('Amba.configure failed', err);
      })
      .finally(() => setReady(true));
  }, []);
 
  if (!ready) {
    return (
      <View style={{ flex: 1, alignItems: 'center', justifyContent: 'center' }}>
        <ActivityIndicator />
      </View>
    );
  }
 
  return <Stack />;
}

If you're starting from the create-expo-app --template default template, keep the existing ThemeProvider + <Stack> wrapper around this — merge the ready-check into the template's root layout rather than replacing it wholesale.

Amba.init() does five things for you:

  1. Builds an AmbaClient with the default on-device storage adapter.
  2. Restores any persisted session (auth.restore()).
  3. Ensures an anonymous_id exists.
  4. Restores cached remote config (config.restore()) and kicks off a background config.refresh().
  5. Prompts for push permission and registers the device token (unless autoRegisterPushToken: false).

6. Auth: email signup + login

Create app/(auth)/signin.tsx:

import { useState } from 'react';
import { View, TextInput, Button, Text, StyleSheet, Alert } from 'react-native';
import { useRouter } from 'expo-router';
import { Amba, AmbaApiError } from '@layers/amba-expo';
 
export default function SignInScreen() {
  const router = useRouter();
  const [email, setEmail] = useState('');
  const [password, setPassword] = useState('');
  const [mode, setMode] = useState<'signin' | 'signup'>('signup');
  const [busy, setBusy] = useState(false);
 
  const submit = async () => {
    if (busy) return;
    setBusy(true);
    try {
      if (mode === 'signup') {
        await Amba.auth.signUpWithEmail(email, password);
      } else {
        await Amba.auth.signInWithEmail(email, password);
      }
      router.replace('/');
    } catch (err) {
      if (err instanceof AmbaApiError) {
        // Common codes: INVALID_CREDENTIALS, USER_EXISTS, INVALID_EMAIL,
        // WEAK_PASSWORD, RATE_LIMITED. See AmbaApiError.code.
        Alert.alert('Auth error', err.message);
      } else {
        Alert.alert('Error', String(err));
      }
    } finally {
      setBusy(false);
    }
  };
 
  return (
    <View style={styles.container}>
      <Text style={styles.title}>{mode === 'signup' ? 'Create account' : 'Sign in'}</Text>
      <TextInput
        style={styles.input}
        placeholder="Email"
        autoCapitalize="none"
        keyboardType="email-address"
        value={email}
        onChangeText={setEmail}
      />
      <TextInput
        style={styles.input}
        placeholder="Password"
        secureTextEntry
        value={password}
        onChangeText={setPassword}
      />
      <Button title={mode === 'signup' ? 'Sign up' : 'Sign in'} onPress={submit} disabled={busy} />
      <Button
        title={mode === 'signup' ? 'I have an account' : 'Create account instead'}
        onPress={() => setMode(mode === 'signup' ? 'signin' : 'signup')}
      />
    </View>
  );
}
 
const styles = StyleSheet.create({
  container: { flex: 1, justifyContent: 'center', padding: 24, gap: 12 },
  title: { fontSize: 28, fontWeight: '600', marginBottom: 12 },
  input: { borderWidth: 1, borderColor: '#ccc', padding: 12, borderRadius: 8 },
});

Now gate the home tab on having a session. With the default template, (tabs)/index.tsx is the home route; drop the auth gate in there (don't create a new app/index.tsx, or it will race with (tabs)/index.tsx):

// app/(tabs)/index.tsx
import { useEffect, useState } from 'react';
import { View, Text, Button, StyleSheet } from 'react-native';
import { useRouter } from 'expo-router';
import { Amba } from '@layers/amba-expo';
import type { Session } from '@layers/amba-expo';
 
export default function HomeScreen() {
  const router = useRouter();
  const [session, setSession] = useState<Session | null>(null);
 
  useEffect(() => {
    (async () => {
      const s = await Amba.auth.getSession();
      if (!s) {
        router.replace('/(auth)/signin');
      } else {
        setSession(s);
      }
    })();
 
    const unsub = Amba.auth.onAuthStateChange((s) => {
      setSession(s);
      if (!s) router.replace('/(auth)/signin');
    });
    return unsub;
  }, []);
 
  if (!session) return null;
 
  return (
    <View style={styles.container}>
      <Text style={styles.title}>Hi {session.user.email ?? session.user.anonymous_id}</Text>
      <Button title="Sign out" onPress={() => Amba.auth.signOut()} />
    </View>
  );
}
 
const styles = StyleSheet.create({
  container: { flex: 1, alignItems: 'center', justifyContent: 'center', gap: 12 },
  title: { fontSize: 20, fontWeight: '600' },
});

Amba.auth.signOut() fires onAuthStateChange(null), so the subscription redirects the user back to the sign-in screen automatically.

Full auth surface (every method available on Amba.auth):

Amba.auth.signInAnonymously(): Promise<AuthResult>
Amba.auth.signInWithEmail(email, password): Promise<AuthResult>
Amba.auth.signUpWithEmail(email, password): Promise<AuthResult>
Amba.auth.signInWithSocial(provider: 'apple' | 'google', idToken): Promise<AuthResult>
Amba.auth.linkAccount(provider: 'apple' | 'google', idToken): Promise<AuthResult>
Amba.auth.getSession(): Promise<Session | null>
Amba.auth.refresh(): Promise<Session>
Amba.auth.me(): Promise<AppUser>
Amba.auth.signOut(rotateAnonymousId?: boolean): Promise<void>
Amba.auth.onAuthStateChange(cb): Unsubscribe

For the Expo one-liner social flows use Amba.signInWithApple() and Amba.signInWithGoogle() — they wrap the native prompt and then call Amba.auth.signInWithSocial(provider, idToken) for you. See Authentication for details.

Run npx expo start and verify you can sign up, sign in, and sign out.

7. Todos: store them as content-library items

Each todo is a content item. The content API supports both reads and writes, so the app can create, edit, and delete todos directly — you don't need a server in front.

Create the library (admin-side, one time)

The library itself is admin-scoped (it defines the collection, not the items). Use an MCP-enabled agent like Cursor or Claude Code with the Amba MCP server configured (see MCP):

Agent: Create a content library named "todos".

amba_content_libraries_create({
  project_id: "proj_xxx",
  name: "todos",
  slug: "todos"
})

Or via the Admin API directly:

POST /admin/content/libraries
Authorization: Bearer <developer token>
 
{ "name": "todos", "slug": "todos" }

The slug you give the library ("todos" above) is the channel your app uses when calling the SDK — no UUID needed.

Read, write, update, and delete todos in the app

The client SDK's ContentModule exposes:

Amba.content.getToday(channel?: string): Promise<ContentItem | null>          // channel defaults to "default"
Amba.content.getLibrary(channel?: string, options?: { limit?: number; cursor?: string }): Promise<ContentItem[]>
Amba.content.getItem(itemId: string): Promise<ContentItem>
Amba.content.createItem(channel: string, input: unknown): Promise<ContentItem>
Amba.content.updateItem(itemId: string, state: unknown): Promise<ContentItem>

The server stamps ownership from the current session — the caller cannot set owner_app_user_id. updateItem and deleteItem return 404 when the target item isn't owned by the current user.

Create app/todos.tsx (or add it as a new tab in (tabs)/ if you want it inside the tab bar — with the default template it's easier to add to the existing tabs group):

import { useEffect, useState, useCallback } from 'react';
import { View, Text, TextInput, FlatList, Button, StyleSheet, Pressable } from 'react-native';
import { Amba } from '@layers/amba-expo';
import type { ContentItem } from '@layers/amba-shared';
 
const CHANNEL = 'todos';
 
export default function TodosScreen() {
  const [todos, setTodos] = useState<ContentItem[]>([]);
  const [draft, setDraft] = useState('');
 
  const load = useCallback(async () => {
    const items = await Amba.content.getLibrary(CHANNEL, { limit: 100 });
    setTodos(items);
  }, []);
 
  useEffect(() => {
    load();
  }, [load]);
 
  const add = async () => {
    if (!draft.trim()) return;
    await Amba.content.createItem(CHANNEL, {
      title: draft.trim(),
      body: '',
      metadata: { completed: false },
    });
    setDraft('');
    await load();
  };
 
  const toggle = async (item: ContentItem) => {
    const completed = (item.metadata as { completed?: boolean } | null)?.completed === true;
    await Amba.content.updateItem(item.id, {
      metadata: { ...(item.metadata ?? {}), completed: !completed },
    });
    if (!completed) {
      // Fire the qualifying event — server-side, any streak definition
      // with `qualifying_event: "todo_completed"` will qualify.
      await Amba.events.track('todo_completed', { todo_id: item.id });
    }
    await load();
  };
 
  const remove = async (item: ContentItem) => {
    await Amba.content.deleteItem(item.id);
    await load();
  };
 
  return (
    <View style={styles.container}>
      <View style={styles.inputRow}>
        <TextInput
          style={styles.input}
          placeholder="Add a todo…"
          value={draft}
          onChangeText={setDraft}
          onSubmitEditing={add}
        />
        <Button title="Add" onPress={add} />
      </View>
      <FlatList
        data={todos}
        keyExtractor={(t) => t.id}
        renderItem={({ item }) => {
          const completed = (item.metadata as { completed?: boolean } | null)?.completed === true;
          return (
            <Pressable
              style={styles.row}
              onPress={() => toggle(item)}
              onLongPress={() => remove(item)}
            >
              <Text style={[styles.title, completed && styles.completed]}>
                {item.title ?? '(untitled)'}
              </Text>
              <Text style={styles.hint}>tap to toggle · long-press to delete</Text>
            </Pressable>
          );
        }}
        ListEmptyComponent={<Text style={{ padding: 16 }}>No todos yet — add one above.</Text>}
      />
    </View>
  );
}
 
const styles = StyleSheet.create({
  container: { flex: 1 },
  inputRow: { flexDirection: 'row', padding: 12, gap: 8, alignItems: 'center' },
  input: { flex: 1, borderWidth: 1, borderColor: '#ccc', padding: 8, borderRadius: 6 },
  row: { padding: 16, borderBottomWidth: 1, borderColor: '#eee' },
  title: { fontSize: 16, fontWeight: '600' },
  completed: { textDecorationLine: 'line-through', color: '#888' },
  hint: { color: '#999', marginTop: 4, fontSize: 12 },
});

To make the screen reachable from the tab bar, add a Link to (tabs)/index.tsx or create a new tab file in app/(tabs)/ that re-exports this screen.

8. Streaks: track "completed a todo today"

Define the streak (admin-side)

Via MCP:

amba_create_streak_definition({
  project_id: "proj_xxx",
  name: "Daily todo",
  slug: "daily-todo",
  qualifying_event: "todo_completed",
  period: "daily",
  grace_period_hours: 6
})

Capture the streak_definition_id — you'll only need it if you plan to call qualify() manually. If you're letting track('todo_completed') drive qualification (as the todo screen above does), no client-side id is required.

Fire the event when a todo is completed

The track() call in Step 7's toggle handler is all you need — the server matches the event name against every streak definition and qualifies whichever ones match:

await Amba.events.track('todo_completed', { todo_id: item.id });

You can also force-qualify without firing an event:

await Amba.streaks.qualify(STREAK_DEFINITION_ID);

Read the current streak

// components/streak-badge.tsx
import { useEffect, useState } from 'react';
import { View, Text } from 'react-native';
import { Amba } from '@layers/amba-expo';
import type { UserStreak } from '@layers/amba-shared';
 
export function StreakBadge() {
  const [streaks, setStreaks] = useState<UserStreak[]>([]);
 
  useEffect(() => {
    Amba.streaks
      .getAll()
      .then(setStreaks)
      .catch(() => setStreaks([]));
  }, []);
 
  const daily = streaks[0];
  if (!daily) return null;
 
  return (
    <View style={{ padding: 12 }}>
      <Text>Current streak: {daily.current_count}</Text>
      <Text>Longest streak: {daily.longest_count}</Text>
    </View>
  );
}

Import it into (tabs)/index.tsx (or todos.tsx) wherever you want it rendered.

9. Remote config: weekly_goal_count

Set the flag via the CLI:

amba config set weekly_goal_count 5

Or via MCP:

amba_set_config({
  project_id: "proj_xxx",
  key: "weekly_goal_count",
  value: 5,
  value_type: "number",
  description: "How many todos the user should complete per week"
})

Read it in the app. The Expo wrapper exposes the config module as configModule (config is renamed to avoid colliding with React prop/state naming):

// components/weekly-goal.tsx
import { useEffect, useState } from 'react';
import { Text } from 'react-native';
import { Amba } from '@layers/amba-expo';
 
export function WeeklyGoal() {
  const [goal, setGoal] = useState<number | null>(null);
 
  useEffect(() => {
    (async () => {
      const value = await Amba.configModule.get('weekly_goal_count');
      setGoal(typeof value === 'number' ? value : 5); // sensible default
    })();
  }, []);
 
  return <Text>Weekly goal: {goal ?? '...'}</Text>;
}

Because Amba.init() already calls restore() and kicks off a refresh() in the background, this read is fast and offline-safe.

Per-segment override

Set the value to 10 for users in a power_users segment:

amba_set_config({
  project_id: "proj_xxx",
  key: "weekly_goal_count",
  value: 5,
  value_type: "number",
  conditions: [{ segment_id: "seg_power_users", value: 10 }]
})

10. Push notifications

Expo plugin handles the native wiring

You already registered @layers/amba-expo as an Expo config plugin in Step 4. That means aps-environment, Apple Sign In capability, URL schemes, and Android intent filters are all added automatically when you prebuild or run expo run:ios / eas build.

You do not need to hand-edit:

  • ios/*.entitlements
  • ios/Info.plist (deep-link schemes)
  • android/app/src/main/AndroidManifest.xml (intent filters)

Push on iOS still requires a development build — Expo Go cannot add custom entitlements. npx expo run:ios or eas build --profile development both produce a dev build.

Configure APNs (iOS)

Via MCP:

amba_integrations_configure({
  project_id: "proj_xxx",
  provider: "apns",
  config: {
    key_id: "ABC123DEFG",
    team_id: "TEAM123456",
    bundle_id: "com.example.todokitchensink",
    apns_key_p8: "-----BEGIN PRIVATE KEY-----\n...\n-----END PRIVATE KEY-----",
    environment: "sandbox"
  }
})

If you don't have an Apple Developer account, skip this and rely on Android for push testing.

Configure FCM (Android)

amba_integrations_configure({
  project_id: "proj_xxx",
  provider: "fcm",
  config: {
    service_account_json: "{ ...the full Firebase service-account JSON... }"
  }
})

Verify the app registered its token

After a successful sign-in on a real device (push requires a physical device — simulators don't get tokens), confirm via MCP or the Admin API:

amba_list_push_tokens({ project_id: "proj_xxx", user_id: "<app_user_id>" })

If you want a custom permission flow (e.g. show your own soft-ask screen first, then prompt the OS), call Amba.registerPushToken() yourself at the moment you want the OS dialog to fire. The call requests permission, fetches the native device token, and forwards to Amba.push.register in one shot:

import { Amba, AmbaApiError } from '@layers/amba-expo';
 
async function onUserOptedIn() {
  try {
    await Amba.registerPushToken();
  } catch (err) {
    if (err instanceof AmbaApiError && err.code === 'PUSH_PERMISSION_DENIED') {
      // User rejected the OS prompt. Surface a "you can change this in Settings" affordance.
    }
  }
}

Send a test push

Via MCP:

amba_push_send_test({
  project_id: "proj_xxx",
  title: "Hello from the kitchen sink",
  body: "If you see this, push works!",
  device_token: "<the token from push_tokens>"
})

Or the CLI:

amba push test

amba push test sends to every registered device for the project.

Send a scheduled campaign

amba_push_campaigns_create({
  project_id: "proj_xxx",
  title: "Time to check off a todo",
  body: "Keep your streak alive!",
  scheduled_at: "2026-04-20T09:00:00Z"
})

The API rejects scheduled_at values in the past — use a future timestamp.

11. Test on iOS and Android

Expo Go

  • Works for: auth, content, streaks, remote config.
  • Does not support iOS push (custom entitlements unavailable).
  • Supports Android push only with the Expo push service — for Amba-owned FCM delivery you need a development build.

Development build

# iOS (requires Xcode, Apple Developer account)
npx expo run:ios
 
# Android (requires Android Studio + emulator or USB device)
npx expo run:android

A development build lets you test push on both platforms and matches your production native config.

Export without running

If you just want to confirm the app bundles, run:

npx expo export --platform ios
npx expo export --platform android

Both should produce a clean bundle with no Metro errors.

12. Deploy to TestFlight / Play internal

Use EAS Build (the standard Expo CI):

npm install -g eas-cli
eas login
eas build:configure
 
# iOS internal (TestFlight)
eas build -p ios --profile preview
 
# Android internal (Play Internal Testing track)
eas build -p android --profile preview

Then submit:

eas submit -p ios --latest
eas submit -p android --latest

Full EAS docs at https://docs.expo.dev/build/setup/.

What you just built

  • Expo app with file-based routing.
  • @layers/amba-expo initialized in the root layout — session, anonymous id, and remote config restored on launch.
  • Email signup + login backed by bcrypt on your project's dedicated database.
  • Todos rendered from a content library, with full create / update / delete from the app.
  • Daily streak driven by Amba.events.track('todo_completed', ...).
  • Remote-config flag (weekly_goal_count) read in the UI.
  • Device push tokens registered automatically on both platforms.
  • Test push + scheduled campaign delivered via Amba.

Every module you just used is covered in depth in the dedicated docs: