Duolingo-style social app (frontend only)
XP, levels, a weekly league, friends with nudges, friend streaks, friend quests, teams and push, for a Firebase, Supabase or React Native app with no server: publishable key, customer session and rules.
This recipe builds the social loop of a learning app (XP for every lesson, a weekly league, friends who nudge each other, a daily friend streak, a weekly friend quest, teams with a shared goal, and push reminders) for an app that has no backend of its own: a web or React Native app on Firebase or Supabase. The app holds only the publishable key and a customer session; everything that pays or ranks runs in Orishare through rules fed by the app's events.
Never put a secret key (osk_) or the agent token in the app. Configuration below is done by you
(the agent) with your agent credential, as change sets; the app never configures anything.
0. Before you start
- A project with a test and a live environment and their publishable keys (Start here for AI agents).
- Sign-in through Firebase or Supabase, exchanged for a customer session: follow
Firebase web app or Supabase web app
for the identity connection, allowed origins and the session code. React Native uses the same
exchange (
POST /v1/customer_sessionswithgrant_type: 'identity_token'); a native app sends noOrigin, so it needs no allowed origin. - The session needs
customer:read,customer:writeandevents:write_client(the defaults).
Social features need a signed-in player. Guest (anonymous) sessions are refused every friend
and team route (403 forbidden, details.reason social_unavailable_for_anonymous) and their
events earn nothing in client mode; ask the player to sign in, and merge the guest's progress
(Anonymous visitors and merge).
1. XP and levels from lessons
Install the levels_and_badges playbook in client mode, so the app's own activity.completed
events earn XP (level_xp), levels and badges:
{
"playbook_id": "levels_and_badges",
"version": "1.0.0",
"parameters": { "event_source": "client", "client_activities_per_day": 50 }
}POST /v1/playbook_previews returns a change set; apply it (POST /v1/change_set_applications,
always allowed in the test environment) or hand it to a person to confirm. It creates activity.completed
as client-allowed at customer_claim and caps how many lessons a day count.
See Playbooks.
2. Tell Orishare which currencies are money
A lesson the app reports is a browser claim (customer_claim). Browser claims never feed a
friend streak, friend quest or team quest that pays a monetary currency, and while the
environment's trust policy is empty every currency counts as monetary. List only your
real-money currencies (or none) so that XP rewards work:
const API = 'https://api.orishare.com';
const current = await (await fetch(`${API}/v1/trust_policy`, { headers: agentHeaders })).json();
await fetch(`${API}/v1/trust_policy`, {
method: 'PATCH',
headers: {
...agentHeaders,
'Content-Type': 'application/json',
'If-Match': `"${current.version}"`,
'Idempotency-Key': 'trust-policy-monetary-1',
},
body: JSON.stringify({ monetary_currencies: ['gems'], caps: [] }),
});In a live environment without auto autonomy this direct write is refused to an agent (403 confirmation_required, reason ai_gate): propose it as a config.apply_document
change set instead (export with GET /v1/config/export?schema=orishare.config/v2, edit its
trust_policy, propose and apply it; see Configuration as code), or ask
the person to make it with their own credential. Rewards in items
({ "type": "item", "item", "quantity" }) are never monetary.
3. The engagement definitions
Propose one change set (POST /v1/change_sets, one Idempotency-Key) with a weekly XP
leaderboard for the league to rank by, and the three social definitions:
{
"commands": [
{ "type": "program.create", "kind": "leaderboard", "key": "weekly_xp", "name": "Weekly XP" },
{
"type": "program.publish_definition",
"key": "weekly_xp",
"definition": {
"schema": "orishare.program/v2",
"kind": "leaderboard",
"semantics": "native_v2",
"definition": {
"kind": "leaderboard",
"aggregation": "sum",
"input": { "minimum": "-1000000", "maximum": "1000000" },
"order": "descending",
"scope": { "type": "global" },
"rank": "ordinal",
"privacy": { "display": "pseudonymous", "opt_out": true, "anonymous_entries": false },
"prizes": [],
"score_source": { "type": "currency_earned", "currency": "level_xp" }
},
"qualification": { "sources": [] },
"calendar": {
"schema": "orishare.calendar/v1",
"period": "week",
"run_per_period": false,
"timezone_source": "environment",
"ambiguous_local_time": "earlier",
"nonexistent_local_time": "next_valid",
"event_time": { "merchant": "occurred_at", "client": "received_at" },
"after_finalization": "correction_only"
},
"trust": {
"minimum_trust": "customer_claim",
"allowed_sources": ["operator_command"],
"on_insufficient": "ignore",
"liability_caps": [],
"recognition_caps": [],
"allow_legacy_unknown": false
},
"correction": { "spendable": "reverse" },
"reward": { "on": {} },
"display": {},
"enrollment": { "mode": "automatic" }
}
},
{
"type": "friend_streak_definition.create",
"key": "duo_streak",
"name": "Friend streak",
"config": {
"schema": "friend_streak/v1",
"timezone": "environment",
"milestones": [
{ "length": 7, "reward": [{ "currency": "level_xp", "amount": 70 }] },
{ "length": 30, "reward": [{ "currency": "level_xp", "amount": 300 }] }
]
}
},
{
"type": "friend_quest_definition.create",
"key": "weekly_duo",
"name": "Friends quest",
"config": {
"schema": "friend_quest/v1",
"period": { "kind": "week", "start_weekday": 1, "timezone": "environment" },
"target": 30,
"reward": [{ "currency": "level_xp", "amount": 100 }]
}
},
{
"type": "team_quest_definition.create",
"key": "team_week",
"name": "Team goal",
"config": {
"schema": "team_quest/v1",
"period": { "kind": "week", "start_weekday": 1, "timezone": "environment" },
"target": 300,
"reward": [{ "currency": "level_xp", "amount": 50 }],
"min_contribution": 5
}
}
]
}The leaderboard needs no rule: its score is the XP each player earned this week. Apply or confirm the change set.
4. The league, the rule and activation
Find the leaderboard's id in GET /v1/programs (key weekly_xp), then propose:
{
"commands": [
{
"type": "league.create",
"key": "weekly",
"name": "Weekly league",
"config": {
"schema": "league/v1",
"tiers": [
{ "key": "bronze", "name": "Bronze", "promote_count": 10 },
{ "key": "silver", "name": "Silver", "promote_count": 7, "demote_count": 5 },
{
"key": "gold",
"name": "Gold",
"demote_count": 5,
"rewards": [{ "rank_from": 1, "rank_to": 3, "currency": "level_xp", "amount": 100 }]
}
],
"cohort_size": 30,
"period": { "kind": "week", "start_weekday": 1, "timezone": "environment" },
"score": { "source": "leaderboard", "program_id": "prg_..." }
}
},
{
"type": "rule.create",
"name": "Lessons feed the friend streak, friend quest and team goal",
"definition": {
"schema": "orishare.rule/v2",
"trigger": { "kind": "customer_event", "event": "activity.completed" },
"trust": { "minimum": "customer_claim" },
"actions": [
{ "type": "friend_streak.activity", "friend_streak": "duo_streak" },
{ "type": "friend_quest.contribute", "friend_quest": "weekly_duo", "amount": 1 },
{ "type": "team_quest.contribute", "team_quest": "team_week", "amount": 1 }
]
}
}
]
}After it is committed, read the revisions (GET /v1/leagues, GET /v1/friend_streak_definitions,
GET /v1/friend_quest_definitions, GET /v1/team_quest_definitions, GET /v1/rules) and propose
the activations and the rule publication:
{
"commands": [
{ "type": "league.activate", "key": "weekly", "expected_version": 1 },
{ "type": "friend_streak_definition.activate", "key": "duo_streak", "expected_version": 1 },
{ "type": "friend_quest_definition.activate", "key": "weekly_duo", "expected_version": 1 },
{ "type": "team_quest_definition.activate", "key": "team_week", "expected_version": 1 },
{ "type": "rule.publish_draft", "key": "<rule id>", "expected_version": 1 }
]
}(use the revisions you read; 1 is what a new definition starts at). The rule needs no change in
your app: every lesson the app reports now also counts for streaks, quests and the team. A player
with no friend quest or no team is simply skipped.
5. The app
One helper for every customer call:
const API = 'https://api.orishare.com';
let customerToken = null; // from your session exchange, kept in memory
async function orishare(path, { method = 'GET', body, key } = {}) {
const res = await fetch(API + '/v1' + path, {
method,
credentials: 'omit',
headers: {
Authorization: `Bearer ${PUBLISHABLE_KEY}`,
'X-Orishare-Customer-Token': customerToken,
...(body ? { 'Content-Type': 'application/json' } : {}),
...(key ? { 'Idempotency-Key': key } : {}),
},
body: body ? JSON.stringify(body) : undefined,
});
if (res.status === 204) return null;
const json = await res.json();
if (!res.ok) throw Object.assign(new Error(json.error.code), { error: json.error });
return json;
}Report a lesson. The event id is the lesson's own id, so a retry counts once; the
Idempotency-Key is one per lesson, reused if you retry:
const lessonKey = `lesson-${lesson.id}-${user.uid}`;
await orishare('/customer/events', {
method: 'POST',
key: lessonKey,
body: {
id: `activity_${lesson.id}_${user.uid}`,
name: 'activity.completed',
properties: { lesson_id: lesson.id },
},
});Friends. Show the player's code, add friends by code, answer requests, remove and block.
Customer social writes need no Idempotency-Key; repeating one returns the current state.
const { code } = await orishare('/customer/friend_code');
await orishare('/customer/friend_requests', { method: 'POST', body: { code: typedCode } });
const incoming = await orishare('/customer/friends?status=incoming');
await orishare(`/customer/friend_requests/${incoming.data[0].friend_code}/accept`, {
method: 'POST',
});
const friends = await orishare('/customer/friends?status=accepted'); // display_name, friend_code
await orishare('/customer/nudges', {
method: 'POST',
body: { code: friend.friend_code, context: 'streak' },
});
await orishare('/customer/high_fives', { method: 'POST', body: { code: friend.friend_code } });
await orishare('/customer/blocks', { method: 'POST', body: { code: friend.friend_code } });
const blocks = await orishare('/customer/blocks'); // { data: [{ handle, display_name }] }
await orishare(`/customer/blocks/${blocks.data[0].handle}`, { method: 'DELETE' });Use PATCH /v1/customer/social_profile to let the player choose a display name and
visibility (public, friends, hidden). Never show customer ids: every route above names
other players by friend code or handle.
Streaks, quests and the league.
const streaks = await orishare('/customer/friend_streaks'); // per friend: length, at_risk, next_milestone
const quests = await orishare('/customer/friend_quests'); // this week's quest per definition
if (!quests.data[0].quest)
await orishare('/customer/friend_quests', {
method: 'POST',
body: { definition: 'weekly_duo', friend_code: friend.friend_code },
});
const leagues = await orishare('/customer/leagues'); // tier, seconds_left, entry.rank, entry.zone
const cohort = await orishare(`/customer/leagues/${leagues.data[0].league.id}/standings`);
const withFriends = await orishare(
`/customer/leagues/${leagues.data[0].league.id}/friend_standings`,
);A player joins this week's league cohort with their first XP of the week.
Teams.
const open = await orishare(`/customer/teams?query=${encodeURIComponent(search)}`);
await orishare('/customer/team_memberships', {
method: 'POST',
body: { team_id: open.data[0].id },
});
// or: body { invite_code } for an invite-only team, or create one:
await orishare('/customer/teams', {
method: 'POST',
body: { name: 'Night Owls', join_policy: 'invite' },
});
const mine = await orishare('/customer/team'); // team, role, members by handle
const goal = await orishare('/customer/team/quests'); // progress, my_contribution, members
const board = await orishare('/customer/team_quest_boards/team_week');On the web, the hosted widget can show the league, friends, team and
notification_preferences surfaces instead of your own screens.
6. Push
Messages need a route. With no backend, the simplest path is your own push provider through a
connector: connect Firebase Cloud Messaging once (POST /v1/notification_connectors with
kind: 'fcm' and the service account, by you or the person, never from the app), register the
device from the app, and route message types to the connector.
// React Native with @react-native-firebase/messaging (web: the FCM web token works the same way)
const token = await messaging().getToken();
await orishare('/customer/notification_devices', {
method: 'POST',
body: { kind: 'fcm', token, platform: Platform.OS, locale: i18n.locale },
});
await orishare('/customer/notification_contact', {
method: 'PATCH',
body: { push_consent: 'opted_in' },
});Then create message types for friend_nudge, friend_streak_at_risk, league_promoted,
league_ending, friend_quest_completed and team_quest_completed, each with
routes: [{ "channel": "connector", "path": "connector", "connector_id": "ncn_..." }]. Orishare
renders the copy in the player's language; let players opt out with
PATCH /v1/customer/notification_preferences. Using OneSignal instead is the recipe
Push through your own OneSignal; built-in sending is
described in Notifications.
7. Test in the test environment
- Sign in two test users in the test environment and make them friends by code.
- Report one lesson for each: both have XP,
GET /v1/customer/friend_streaksshows length 1,GET /v1/customer/leaguesshows an entry with a rank. - Start the friend quest from one of them and report lessons from both until the target:
GET /v1/customer/friend_questsshowscompleted, both balances grew. - Create a team, join it with the other user, report lessons:
GET /v1/customer/team/questsshows the progress and each member's contribution. - Nudge:
GET /v1/notification_messages?type_key=friend_nudgeshows the message (in a test environment connectors are simulated: the message is recorded as sent and reaches no device). - Block one from the other: the friendship and the active friend quest end, and neither appears in the other's friend standings.
8. Go live
Repeat steps 1 to 4 in the live environment (the person confirms the change sets, and sets
the trust policy if your credential may not), ship the app with the live publishable key,
and watch GET /v1/notification_message_stats and the league periods
(GET /v1/leagues/{id}/periods) during the first week.