Orishare
Recipes

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_sessions with grant_type: 'identity_token'); a native app sends no Origin, so it needs no allowed origin.
  • The session needs customer:read, customer:write and events: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

  1. Sign in two test users in the test environment and make them friends by code.
  2. Report one lesson for each: both have XP, GET /v1/customer/friend_streaks shows length 1, GET /v1/customer/leagues shows an entry with a rank.
  3. Start the friend quest from one of them and report lessons from both until the target: GET /v1/customer/friend_quests shows completed, both balances grew.
  4. Create a team, join it with the other user, report lessons: GET /v1/customer/team/quests shows the progress and each member's contribution.
  5. Nudge: GET /v1/notification_messages?type_key=friend_nudge shows the message (in a test environment connectors are simulated: the message is recorded as sent and reaches no device).
  6. 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.

On this page