What you’ll build
A simple React Native app that:
- Connects a user to Sahha
- Fetches recent Sahha scores (Sleep / Activity / Readiness / Mental Wellbeing)
- Chooses a daily Nutrition Mode (e.g., Recovery, Performance)
- Renders Smart Defaults (meals + grocery list)
- On “Shop with Instacart”, creates an Instacart shopping list page via your backend and deep-links the user into Instacart
This turns nutrition “recommendations” into an actionable engagement feature:
State → Defaults → One-tap checkout
Architecture
Instacart API keys should never live in a mobile app. Use a tiny backend to create the shopping list page.
React Native App
├─ Sahha SDK → scores/state
├─ Mode selection → grocery line items
└─ POST /instacart/shopping-list (your backend)
└─ Instacart API → returns products_link_url
└─ Linking.openURL(products_link_url) → Instacart app / web
Prerequisites
- React Native app (Expo dev build or plain RN)
- Sahha credentials:
SAHHA_APP_IDSAHHA_APP_SECRET
- Instacart Developer Platform API key (server-side)
- A stable
externalIdper user (your internal user id)
Note: This tutorial focuses on the engagement loop and the integration pattern. Adjust score types/sensors to match your product and Sahha configuration.
Project structure
You’ll create two small projects:
nutrition-smart-defaults/
app/ # React Native
App.tsx
src/
sahhaClient.ts
scoreNormalize.ts
nutritionModes.ts
instacart.ts
modeToCart.ts
backend/ # Node/Express
server.js
package.json
Part 1 — Backend (Node/Express) for Instacart
1. Create the backend
mkdir -p nutrition-smart-defaults/backend
cd nutrition-smart-defaults/backend
npm init -y
npm i express cors
2. Add server.js
This endpoint receives
line_items, calls Instacart’s “create shopping list page” endpoint, and returns aproducts_link_urlfor deep linking.
// backend/server.js
import express from "express";
import cors from "cors";
const app = express();
app.use(cors());
app.use(express.json());
const INSTACART_API_KEY = process.env.INSTACART_API_KEY;
// Use the correct base URL for your environment.
// - Development typically uses a dev base.
// - Production uses the production base.
// Keep the base URL configurable so you can swap without code changes.
const INSTACART_BASE_URL =
process.env.INSTACART_BASE_URL || "https://connect.dev.instacart.tools";
function assertEnv() {
if (!INSTACART_API_KEY) throw new Error("Missing INSTACART_API_KEY");
}
app.post("/instacart/shopping-list", async (req, res) => {
try {
assertEnv();
const { title, line_items } = req.body;
if (!title || typeof title !== "string") {
return res.status(400).json({ error: "title is required (string)" });
}
if (!Array.isArray(line_items) || line_items.length === 0) {
return res.status(400).json({ error: "line_items must be a non-empty array" });
}
// Minimal validation: name is required for name-based matching.
for (const [i, item] of line_items.entries()) {
if (!item?.name || typeof item.name !== "string") {
return res.status(400).json({ error: `line_items[${i}].name is required` });
}
}
const url = `${INSTACART_BASE_URL}/idp/v1/products/products_link`;
const payload = {
title,
link_type: "shopping_list",
line_items,
landing_page_configuration: {
// Useful for weekly repeat behavior: users can exclude items they already have.
enable_pantry_items: true,
// Optional: deep link back into your app after shopping
// partner_linkback_url: "yourapp://smart-defaults"
},
};
const response = await fetch(url, {
method: "POST",
headers: {
Accept: "application/json",
"Content-Type": "application/json",
Authorization: `Bearer ${INSTACART_API_KEY}`,
},
body: JSON.stringify(payload),
});
const body = await response.json().catch(() => ({}));
if (!response.ok) {
return res.status(response.status).json({
error: "Instacart request failed",
details: body,
});
}
return res.json({ products_link_url: body.products_link_url });
} catch (err) {
return res.status(500).json({ error: err?.message ?? "Server error" });
}
});
app.listen(8080, () => {
console.log("Backend running on http://localhost:8080");
});
3. Run the backend
# from nutrition-smart-defaults/backend
export INSTACART_API_KEY="YOUR_INSTACART_KEY"
export INSTACART_BASE_URL="https://connect.dev.instacart.tools"
node server.js
Device testing tip
If you’re testing on a physical phone, localhost won’t resolve to your laptop.
Use one of:
- Your machine LAN IP (e.g.,
http://192.168.x.x:8080) - A secure tunnel (e.g., ngrok)
- Emulator host mapping (Android emulator often uses
http://10.0.2.2:8080)
Part 2 — React Native App (Sahha → Defaults → Instacart)
1. Create the app
mkdir -p nutrition-smart-defaults/app
cd nutrition-smart-defaults/app
# Create your RN project (Expo or plain RN)
# Expo note: use a dev build if you need native modules.
Install Sahha SDK:
npm i sahha-react-native
Note: If you’re using Expo, you’ll need a development build to use native modules.
2. Sahha wrapper (promisify callbacks)
Create src/sahhaClient.ts:
// app/src/sahhaClient.ts
import Sahha from "sahha-react-native";
/**
* The Sahha RN SDK uses callback-style methods.
* This wrapper converts them to Promises for cleaner app code.
*
* Adjust method names / enums to match your Sahha SDK version.
*/
export async function sahhaConfigure(environment: any) {
return new Promise<boolean>((resolve, reject) => {
Sahha.configure({ environment }, (error: string, success: boolean) => {
if (error) return reject(new Error(error));
resolve(success);
});
});
}
export async function sahhaAuthenticate(appId: string, appSecret: string, externalId: string) {
return new Promise<boolean>((resolve, reject) => {
Sahha.authenticate(appId, appSecret, externalId, (error: string, success: boolean) => {
if (error) return reject(new Error(error));
resolve(success);
});
});
}
export async function sahhaEnableSensors(sensors: any[]) {
return new Promise<any>((resolve, reject) => {
Sahha.enableSensors(sensors, (error: string, value: any) => {
if (error) return reject(new Error(error));
resolve(value);
});
});
}
export type RawScore = Record<string, any>;
export async function sahhaGetScores(scoreTypes: any[], startMs: number, endMs: number) {
return new Promise<RawScore[]>((resolve, reject) => {
Sahha.getScores(scoreTypes, startMs, endMs, (error: string, value: string) => {
if (error) return reject(new Error(error));
try {
const parsed = JSON.parse(value || "[]");
resolve(Array.isArray(parsed) ? parsed : []);
} catch {
resolve([]);
}
});
});
}
export function sahhaOpenAppSettings() {
Sahha.openAppSettings();
}
3. Normalize score payloads (defensive extraction)
Create src/scoreNormalize.ts:
// app/src/scoreNormalize.ts
import type { RawScore } from "./sahhaClient";
/**
* Score payload shape may vary by SDK version.
* This helper tries common keys and returns the latest value for a given score type.
*/
export function extractLatestScoreValue(scores: RawScore[], scoreType: string): number | undefined {
const candidates = scores
.map((s) => {
const t = (s.type ?? s.scoreType ?? s.name ?? "").toString();
const v = s.score ?? s.value ?? s.scoreValue ?? s.data?.score ?? s.data?.value;
const end = s.endDateTime ?? s.end ?? s.timestamp ?? s.dateTime;
return {
t,
v: typeof v === "number" ? v : Number(v),
end: typeof end === "number" ? end : Number(end),
};
})
.filter((x) => x.t === scoreType && x.v != null && !Number.isNaN(x.v));
if (!candidates.length) return undefined;
candidates.sort((a, b) => (b.end || 0) - (a.end || 0));
return candidates[0].v;
}
/** Some stacks return scores 0..100, others 0..1. Normalize to 0..1. */
export function to01(x?: number): number | undefined {
if (x == null || Number.isNaN(x)) return undefined;
const v = x > 1 ? x / 100 : x;
return Math.max(0, Math.min(1, v));
}
4. Define Nutrition Modes + Smart Defaults
Create src/nutritionModes.ts:
// app/src/nutritionModes.ts
import { to01 } from "./scoreNormalize";
export type NutritionModeId = "RECOVERY" | "PERFORMANCE" | "STRESS_BUFFER" | "BALANCED";
export type DailySignals = {
sleep?: number;
readiness?: number;
activity?: number;
mentalWellbeing?: number;
};
export type SmartDefaults = {
mode: NutritionModeId;
title: string;
rationale: string;
meals: string[];
groceries: string[];
nudges: string[];
};
export function chooseNutritionMode(signals: DailySignals): NutritionModeId {
const sleep = to01(signals.sleep);
const readiness = to01(signals.readiness);
const activity = to01(signals.activity);
const mental = to01(signals.mentalWellbeing);
// Simple, engagement-first logic:
// - Low sleep/readiness → recovery defaults (reduce decision load)
// - High activity → performance fueling
// - Low mental wellbeing → stress-buffer defaults (simplicity + stable patterns)
if ((sleep != null && sleep < 0.45) || (readiness != null && readiness < 0.45)) return "RECOVERY";
if (activity != null && activity > 0.70) return "PERFORMANCE";
if (mental != null && mental < 0.45) return "STRESS_BUFFER";
return "BALANCED";
}
export function defaultsFor(mode: NutritionModeId): SmartDefaults {
switch (mode) {
case "RECOVERY":
return {
mode,
title: "Recovery Day Defaults",
rationale: "Lower sleep/readiness → keep meals simple, stabilize energy, prioritize protein + fiber.",
meals: [
"Breakfast: Greek yogurt or eggs + fruit",
"Lunch: protein + veg + slow carb",
"Dinner: simple bowl (salmon/tofu + greens + olive oil)",
],
groceries: ["Greek yogurt / eggs", "Fruit", "Leafy greens", "Protein (salmon/tofu/chicken)", "Rice/quinoa", "Olive oil", "Nuts"],
nudges: ["Hydration reminder", "Caffeine earlier", "One easy veg add-on"],
};
case "PERFORMANCE":
return {
mode,
title: "Performance Day Defaults",
rationale: "Higher activity → fuel output + recovery. Add carbs intentionally and distribute protein.",
meals: [
"Breakfast: oats + protein",
"Lunch: carb-forward bowl + protein",
"Snack: fruit + nuts",
"Dinner: protein + veg + carb",
],
groceries: ["Oats", "Rice/potatoes", "Fruit", "Lean protein", "Yogurt"],
nudges: ["Post-activity meal timing suggestion", "One-tap carb add-on"],
};
case "STRESS_BUFFER":
return {
mode,
title: "Stress-Buffer Defaults",
rationale: "Lower mental wellbeing → prioritize simplicity + stable patterns. Reduce decisions, reduce spikes.",
meals: [
"Breakfast: savory protein + fiber",
"Lunch: warm comfort meal (protein + veg)",
"Dinner: high-fiber plate (beans/veg + protein)",
],
groceries: ["Frozen veg", "Beans/lentils", "Soup base", "Eggs", "Optional treat"],
nudges: ["Keep it simple today", "Repeat yesterday’s plan"],
};
default:
return {
mode: "BALANCED",
title: "Balanced Defaults",
rationale: "No strong signal → general-purpose defaults that are easy to repeat.",
meals: ["Breakfast: protein + fruit", "Lunch: protein + veg", "Dinner: protein + veg + optional carb"],
groceries: ["Protein staples", "Fruit", "Veg (fresh/frozen)", "Whole-grain carb option"],
nudges: ["One higher-fiber swap suggestion"],
};
}
}
Note: This is not medical advice. It’s a product pattern: reduce friction and increase adherence.
5. Instacart client (calls your backend)
Create src/instacart.ts:
// app/src/instacart.ts
export type InstacartLineItem = {
name: string;
quantity?: number;
unit?: string; // e.g. "each"
display_text?: string; // optional, for nicer UX
};
export async function createInstacartShoppingList(
backendUrl: string,
title: string,
line_items: InstacartLineItem[]
): Promise<string> {
const resp = await fetch(`${backendUrl}/instacart/shopping-list`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ title, line_items }),
});
const body = await resp.json();
if (!resp.ok) throw new Error(body?.error ?? "Failed to create Instacart list");
if (!body?.products_link_url) throw new Error("Missing products_link_url");
return body.products_link_url as string;
}
6. Map modes → Instacart line items
Create src/modeToCart.ts:
// app/src/modeToCart.ts
import type { NutritionModeId } from "./nutritionModes";
import type { InstacartLineItem } from "./instacart";
/**
* Start with name-based matching. You can refine later by adding brand/health filters
* or more specific item names based on what performs best.
*/
export function buildInstacartItems(mode: NutritionModeId): InstacartLineItem[] {
switch (mode) {
case "RECOVERY":
return [
{ name: "Greek yogurt", quantity: 1, unit: "each" },
{ name: "Eggs", quantity: 1, unit: "each" },
{ name: "Bananas", quantity: 6, unit: "each" },
{ name: "Leafy greens", quantity: 1, unit: "each" },
{ name: "Salmon", quantity: 1, unit: "each" },
{ name: "Quinoa", quantity: 1, unit: "each" },
{ name: "Olive oil", quantity: 1, unit: "each" },
{ name: "Mixed nuts", quantity: 1, unit: "each" },
];
case "PERFORMANCE":
return [
{ name: "Oats", quantity: 1, unit: "each" },
{ name: "Bananas", quantity: 6, unit: "each" },
{ name: "Rice", quantity: 1, unit: "each" },
{ name: "Potatoes", quantity: 1, unit: "each" },
{ name: "Chicken breast", quantity: 1, unit: "each" },
{ name: "Greek yogurt", quantity: 1, unit: "each" },
];
case "STRESS_BUFFER":
return [
{ name: "Frozen vegetables", quantity: 1, unit: "each" },
{ name: "Lentils", quantity: 1, unit: "each" },
{ name: "Soup", quantity: 1, unit: "each" },
{ name: "Eggs", quantity: 1, unit: "each" },
];
default:
return [
{ name: "Eggs", quantity: 1, unit: "each" },
{ name: "Fruit", quantity: 1, unit: "each" },
{ name: "Vegetables", quantity: 1, unit: "each" },
{ name: "Chicken breast", quantity: 1, unit: "each" },
{ name: "Whole grain bread", quantity: 1, unit: "each" },
];
}
}
7. Build the app screen (Sahha → Defaults → Instacart deep link)
Replace your App.tsx with:
// app/App.tsx
import React, { useMemo, useState } from "react";
import { SafeAreaView, View, Text, Button, ScrollView, ActivityIndicator, Alert, Linking } from "react-native";
import { sahhaConfigure, sahhaAuthenticate, sahhaEnableSensors, sahhaGetScores, sahhaOpenAppSettings } from "./src/sahhaClient";
import { extractLatestScoreValue } from "./src/scoreNormalize";
import { chooseNutritionMode, defaultsFor, type NutritionModeId } from "./src/nutritionModes";
import { createInstacartShoppingList } from "./src/instacart";
import { buildInstacartItems } from "./src/modeToCart";
// ====== Configure these ======
const SAHHA_APP_ID = "YOUR_SAHHA_APP_ID";
const SAHHA_APP_SECRET = "YOUR_SAHHA_APP_SECRET";
const EXTERNAL_ID = "demo-user-123";
// Backend URL:
// - iOS simulator can often use http://localhost:8080
// - Android emulator often uses http://10.0.2.2:8080
// - physical device needs LAN IP or tunnel
const BACKEND_URL = "http://localhost:8080";
// These values depend on your Sahha SDK version.
// Replace with your SDK's valid environment/sensor/score constants.
const SAHHA_ENVIRONMENT: any = "sandbox";
const SENSOR_SET: any[] = ["sleep", "steps", "heart_rate", "resting_heart_rate"];
const SCORE_SET: any[] = ["sleep", "activity", "readiness", "mental_wellbeing"];
export default function App() {
const [loading, setLoading] = useState(false);
const [connected, setConnected] = useState(false);
const [scoresRaw, setScoresRaw] = useState<any[]>([]);
const signals = useMemo(() => {
return {
sleep: extractLatestScoreValue(scoresRaw, "sleep"),
activity: extractLatestScoreValue(scoresRaw, "activity"),
readiness: extractLatestScoreValue(scoresRaw, "readiness"),
mentalWellbeing: extractLatestScoreValue(scoresRaw, "mental_wellbeing"),
};
}, [scoresRaw]);
const mode: NutritionModeId = useMemo(() => chooseNutritionMode(signals), [signals]);
const defaults = useMemo(() => defaultsFor(mode), [mode]);
async function connectSahha() {
setLoading(true);
try {
await sahhaConfigure(SAHHA_ENVIRONMENT);
await sahhaAuthenticate(SAHHA_APP_ID, SAHHA_APP_SECRET, EXTERNAL_ID);
const sensorStatus = await sahhaEnableSensors(SENSOR_SET);
// If permissions were denied/disabled, guide the user to settings.
// Your SDK may expose explicit status enums; treat this as a pattern.
if (sensorStatus === "disabled" || sensorStatus === 0) {
Alert.alert(
"Permissions required",
"Health permissions are disabled. Enable access in device settings.",
[{ text: "Open Settings", onPress: () => sahhaOpenAppSettings() }, { text: "OK" }]
);
}
setConnected(true);
} catch (e: any) {
Alert.alert("Connect failed", e?.message ?? "Unknown error");
} finally {
setLoading(false);
}
}
async function refreshScores() {
setLoading(true);
try {
const end = Date.now();
const start = end - 7 * 24 * 60 * 60 * 1000; // last 7 days
const scores = await sahhaGetScores(SCORE_SET, start, end);
setScoresRaw(scores);
} catch (e: any) {
Alert.alert("Score fetch failed", e?.message ?? "Unknown error");
} finally {
setLoading(false);
}
}
async function shopWithInstacart() {
setLoading(true);
try {
const items = buildInstacartItems(mode);
const title = `Smart Defaults (${mode})`;
const url = await createInstacartShoppingList(BACKEND_URL, title, items);
// Instacart URLs are intended to deep-link into the Instacart app when available.
await Linking.openURL(url);
} catch (e: any) {
Alert.alert("Could not open Instacart", e?.message ?? "Unknown error");
} finally {
setLoading(false);
}
}
return (
<SafeAreaView style={{ flex: 1, padding: 16 }}>
<ScrollView>
<Text style={{ fontSize: 22, fontWeight: "700", marginBottom: 12 }}>
Nutrition Smart Defaults
</Text>
{!connected ? (
<View style={{ marginBottom: 16 }}>
<Text style={{ marginBottom: 8 }}>
Connect Sahha to generate daily, state-based nutrition defaults.
</Text>
{loading ? <ActivityIndicator /> : <Button title="Connect Sahha" onPress={connectSahha} />}
</View>
) : (
<View style={{ marginBottom: 16 }}>
{loading ? <ActivityIndicator /> : <Button title="Refresh scores" onPress={refreshScores} />}
</View>
)}
<View style={{ padding: 12, borderWidth: 1, borderRadius: 12, marginBottom: 12 }}>
<Text style={{ fontSize: 16, fontWeight: "700" }}>Signals</Text>
<Text>Sleep: {signals.sleep ?? "—"}</Text>
<Text>Activity: {signals.activity ?? "—"}</Text>
<Text>Readiness: {signals.readiness ?? "—"}</Text>
<Text>Mental wellbeing: {signals.mentalWellbeing ?? "—"}</Text>
</View>
<View style={{ padding: 12, borderWidth: 1, borderRadius: 12, marginBottom: 12 }}>
<Text style={{ fontSize: 16, fontWeight: "700" }}>{defaults.title}</Text>
<Text style={{ marginTop: 6 }}>{defaults.rationale}</Text>
<Text style={{ marginTop: 10, fontWeight: "700" }}>Meals</Text>
{defaults.meals.map((m) => (
<Text key={m} style={{ marginTop: 6 }}>• {m}</Text>
))}
<Text style={{ marginTop: 10, fontWeight: "700" }}>Groceries</Text>
{defaults.groceries.map((g) => (
<Text key={g} style={{ marginTop: 6 }}>• {g}</Text>
))}
<Text style={{ marginTop: 10, fontWeight: "700" }}>Nudges</Text>
{defaults.nudges.map((n) => (
<Text key={n} style={{ marginTop: 6 }}>• {n}</Text>
))}
</View>
<Button title={loading ? "Working..." : "Shop with Instacart"} onPress={shopWithInstacart} disabled={loading || !connected} />
<Text style={{ marginTop: 12, fontSize: 12, opacity: 0.7 }}>
This demo illustrates a product pattern (state → defaults → action). It is not medical advice.
</Text>
</ScrollView>
</SafeAreaView>
);
}
Why this works (engagement mechanics)
This is the key: defaults are not content. Defaults are decision removal.
- Fewer decisions → higher follow-through
- State-aware → feels personal and timely
- One-tap action → converts intention into behavior
- Commerce integration → closes the loop (recommend → buy)
Part 3 — Make it “real”: caching + instrumentation
1) Cache shopping list links (weekly repeat)
Instacart shopping list page creation is fast, but you’ll get a better UX if you cache by a deterministic key.
Example key on backend:
cacheKey = hash(userId + weekStart + mode + sortedItemNames)
If cached, return the existing products_link_url. Only regenerate when items change.
2) Instrument what users do (so defaults improve)
Track events like:
smart_defaults_viewedsmart_defaults_acceptedsmart_defaults_modified(item removed / swapped)instacart_openedinstacart_returned
If you’re using Sahha tags/events (or your own analytics), these signals let you learn:
- which mode drives the best conversion
- which items get removed most
- which defaults lead to repeat weekly use
Production checklist
- Instacart key stored server-side only
- Switch Instacart base URL to production when approved
- Add rate limiting and basic auth to your backend endpoint
- Use a stable
externalIdfor Sahha - Handle permissions cleanly (settings deep link)
- Add caching + instrumentation for repeatability
Next steps
Once the integration works end-to-end, the big upgrades are:
- Preferences as modifiers (veg / gluten-free / high protein)
- “Repeat last week” button (instant engagement)
- Timing engine: prompt shopping during the user’s best planning window
- A/B test modes: different default templates per segment
Troubleshooting
“Instacart link opens web instead of app”
That can happen if the Instacart app isn’t installed or the OS doesn’t hand off the deep link. This is expected. Your flow still works via web.
“Backend works on laptop but not on phone”
Use LAN IP, a tunnel, or emulator mapping. Don’t use localhost on a physical device.
“Scores are empty”
Common causes:
- permissions not granted
- not enough data collected yet
- time window too narrow
- score type strings don’t match your SDK config
Start by widening the score query window (e.g., 14–30 days) and verifying sensors are enabled.
If you want, I can adapt this MDX to your exact Sahha SDK constants (enums for environment/sensors/score types) and the exact MDX components used on resources.sahha.ai (Callouts, Steps, Tabs), so it drops in with zero editing.