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.

This tutorial walks an AI agent (or a human) through building a real Expo to-do app that uses Amba end-to-end:

  • Email signup / login.
  • Todos stored as content-library items (reads and writes from the app).
  • A daily "completed a todo" streak.
  • A remote-config feature flag (weekly_goal_count).
  • Push notifications on both platforms.

When you're done you'll have a TestFlight / Play-internal-ready app that demonstrates every major SDK module.

If you get stuck because this doc is ambiguous, the doc is wrong — file an issue.

1. Prerequisites

  • Node.js 20 or later (node -v). Node 22 is fine too.
  • pnpm 10 or later (pnpm -v). The Amba monorepo uses pnpm workspaces; Path A below requires pnpm. npm / yarn / bun only work for Path B once the packages ship on npm.
  • Expo CLI — we invoke 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.

If you don't have an Apple Developer account ($99/yr), you can still build and run the app — you just won't be able to test iOS push or submit to TestFlight. Skip the iOS push section when you get there.

Push notifications require a development build on iOS (Expo Go does not support custom entitlements). Call this out up-front so you don't waste time trying to test push in Expo Go.

2. Install the SDK (two paths)

There are two supported install paths. Path A is the reality today. Path B is the published-to-npm story and is not yet working because @amba/cli, @amba/client, and @amba/expo have not been published.

The monorepo already contains a ready-to-run kitchen-sink example at examples/expo-todo-kitchen-sink. examples/* is part of the pnpm workspace, so the demo consumes the SDKs via the workspace:* protocol — pnpm symlinks the live source, and any edit to packages/client/src/** shows up in the app immediately.

git clone https://github.com/AppMachina/amba.git
cd amba
pnpm install            # installs every package in the workspace
pnpm -r run build       # builds shared, client, expo, cli, mcp
 
# The kitchen-sink app is already set up with workspace deps.
cd examples/expo-todo-kitchen-sink
pnpm install
npx expo start

Want to start from scratch instead? Create a new Expo app inside examples/ so it's part of the pnpm workspace automatically:

cd examples
npx create-expo-app@latest my-app --template default
cd my-app
# Add the Amba packages as workspace deps:
pnpm add @amba/expo@workspace:* @amba/client@workspace:* @amba/shared@workspace:*
pnpm add expo-notifications expo-device \
  expo-apple-authentication expo-auth-session \
  @react-native-async-storage/async-storage

Then run the CLI from the monorepo root to create an Amba project + mint an API key:

# From the repo root:
pnpm amba init

The root package.json exposes an "amba" script ("amba": "pnpm --filter amba exec node dist/index.js"), so pnpm amba <args> runs the CLI against the workspace build. You do not need to install anything globally.

What pnpm amba init does

  1. Authenticate — opens a browser for login, or reuses ~/.amba/credentials.json.
  2. Store credentials — writes ~/.amba/credentials.json (chmod 0600).
  3. Select or create a project — pick existing, or type a name for a new project.
  4. Generate API keys — mints a development client API key (amb_dev_ck_...).
  5. Write environment file — writes .env.local with AMBA_PROJECT_ID, AMBA_API_KEY, and AMBA_API_URL in the directory you ran the command from.
  6. Detect framework — writes AMBA.md + .cursor/rules/amba.mdc so your AI agent has project context.

Re-run from inside examples/my-app if you want those files placed next to the app (recommended):

cd examples/my-app
pnpm --filter amba exec node ../../packages/cli/dist/index.js init

Path B — npm install (coming soon)

Once @amba/cli, @amba/client, and @amba/expo are published to npm, this will be the preferred path:

# Not yet working — the packages are not on npm.
npx create-expo-app@latest my-app --template default
cd my-app
npx amba init
npx expo install @amba/expo expo-notifications expo-device \
  expo-apple-authentication expo-auth-session \
  @react-native-async-storage/async-storage

Until then, use Path A. The rest of this tutorial assumes Path A — anywhere it says pnpm amba ..., substitute npx amba ... once the packages ship.

3. Environment variables

Expo only exposes env vars that start with EXPO_PUBLIC_ to client-side code. The CLI writes un-prefixed names (AMBA_PROJECT_ID) because the same file may be used by server-side tooling. For the Expo app, rewrite .env.local so every client-side value has the prefix:

# examples/my-app/.env.local  (or wherever your Expo app lives)
 
# Client-side — embedded in the JS bundle, visible to end users.
EXPO_PUBLIC_AMBA_PROJECT_ID=proj_xxx
EXPO_PUBLIC_AMBA_API_KEY=amb_dev_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 @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",
      [
        "@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 '@amba/expo';
 
export default function RootLayout() {
  const [ready, setReady] = useState(false);
 
  useEffect(() => {
    Amba.init({
      projectId: process.env.EXPO_PUBLIC_AMBA_PROJECT_ID!,
      apiKey: process.env.EXPO_PUBLIC_AMBA_API_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.init 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 asyncStorageAdapter 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)/login.tsx:

import { useState } from 'react';
import { View, TextInput, Button, Text, StyleSheet, Alert } from 'react-native';
import { useRouter } from 'expo-router';
import { Amba } from '@amba/expo';
import { AmbaApiError } from '@amba/client';
 
export default function LoginScreen() {
  const router = useRouter();
  const [email, setEmail] = useState('');
  const [password, setPassword] = useState('');
  const [mode, setMode] = useState<'login' | '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.loginWithEmail(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' : 'Log in'} onPress={submit} disabled={busy} />
      <Button
        title={mode === 'signup' ? 'I have an account' : 'Create account instead'}
        onPress={() => setMode(mode === 'signup' ? 'login' : '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 '@amba/expo';
import type { Session } from '@amba/client';
 
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)/login');
      } else {
        setSession(s);
      }
    })();
 
    const unsub = Amba.auth.onAuthStateChange((s) => {
      setSession(s);
      if (!s) router.replace('/(auth)/login');
    });
    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="Log out" onPress={() => Amba.auth.logout()} />
    </View>
  );
}
 
const styles = StyleSheet.create({
  container: { flex: 1, alignItems: 'center', justifyContent: 'center', gap: 12 },
  title: { fontSize: 20, fontWeight: '600' },
});

Amba.auth.logout() calls notify(null) internally, so the onAuthStateChange subscription fires and redirects the user to the login screen automatically.

Full AuthModule surface (all exposed on Amba.auth):

Amba.auth.getAnonymousId(): Promise<string>
Amba.auth.signUpWithEmail(email, password): Promise<AuthResult>
Amba.auth.loginWithEmail(email, password): Promise<AuthResult>
Amba.auth.loginWithApple(identityToken): Promise<AuthResult>
Amba.auth.loginWithGoogle(idToken): Promise<AuthResult>
Amba.auth.linkAccount(provider: 'apple' | 'google', token): Promise<AuthResult>
Amba.auth.getSession(): Promise<Session | null>
Amba.auth.refresh(): Promise<Session>
Amba.auth.me(): Promise<AppUser>
Amba.auth.logout(): 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 loginWithApple/Google for you. See Authentication for details.

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

7. Todos: store them as content-library items

Each todo is a content_items row. The ContentModule ships read and write endpoints, 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_create_content_library({
  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" }

Capture the returned library_id and put it in .env.local (it's not a secret, but env-var-ification keeps it out of source):

EXPO_PUBLIC_AMBA_TODOS_LIBRARY_ID=lib_xxx

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

The client SDK's ContentModule exposes:

Amba.content.getToday(): Promise<ContentItem[]>
Amba.content.getLibrary(libraryId, options?): Promise<ContentItem[]>
Amba.content.getItem(itemId): Promise<ContentItem>
Amba.content.createItem(libraryId, input): Promise<ContentItem>
Amba.content.updateItem(itemId, input): Promise<ContentItem>
Amba.content.deleteItem(itemId): Promise<void>

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 '@amba/expo';
import type { ContentItem } from '@amba/shared';
 
const LIBRARY_ID = process.env.EXPO_PUBLIC_AMBA_TODOS_LIBRARY_ID!;
 
export default function TodosScreen() {
  const [todos, setTodos] = useState<ContentItem[]>([]);
  const [draft, setDraft] = useState('');
 
  const load = useCallback(async () => {
    const items = await Amba.content.getLibrary(LIBRARY_ID, { limit: 100 });
    setTodos(items);
  }, []);
 
  useEffect(() => {
    load();
  }, [load]);
 
  const add = async () => {
    if (!draft.trim()) return;
    await Amba.content.createItem(LIBRARY_ID, {
      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.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.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 '@amba/expo';
import type { UserStreak } from '@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 (from the monorepo root):

pnpm 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 '@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 @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_configure_integration({
  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_configure_integration({
  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 to defer the permission prompt (e.g. show your own soft-ask screen first), pass autoRegisterPushToken: false to Amba.init({...}) and call Amba.registerPushToken() yourself later.

Send a test push

Via MCP:

amba_send_test_push({
  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:

pnpm amba push test

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

Send a scheduled campaign

amba_create_push_campaign({
  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.
  • @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.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: