Skip to main content

Intent Social Recipes

This guide shows how partner apps can implement common social product flows with the current protocol SDK.

These are recipes over the shipped primitives, not extra endpoints.

Product recipeProtocol primitive
matchPeoplecreateIntent when needed, then getIntentAssembly and read person items
createRoomgetIntentAssembly, then createCircle only after user confirmation
requestIntrogetIntentAssembly, then sendRequest for the selected person
subscribeToIntentcreateWebhook, replayEvents, getReplayCursor, and saveReplayCursor

Shared setup

Use a bound app client after app registration and token storage:

import { createBoundProtocolAppClientFromBaseUrl } from '@opensocial/protocol-client';

function requireEnv(name: string): string {
const value = process.env[name];
if (!value) {
throw new Error(`Missing required environment variable: ${name}`);
}
return value;
}

const app = createBoundProtocolAppClientFromBaseUrl('https://api.intent.you/api', {
appId: requireEnv('INTENT_APP_ID'),
appToken: requireEnv('INTENT_APP_TOKEN'),
});

Every write requires:

  • actions.invoke
  • the matching capability, such as intent.write, request.write, or circle.write
  • an executable delegated user grant

Assembly reads require the intent_assembly.read capability.

matchPeople

Use this when the user says something like:

I want to meet people building consumer AI apps.

The partner flow is:

  1. create or reuse an active intent
  2. read its assembly
  3. render people with the protocol-provided reason, strength, and action
  4. wait for the user to choose a next step
const created = await app.createIntent({
actorUserId,
rawText: 'I want to meet people building consumer AI apps.',
metadata: {
source: 'partner.recipe.match_people',
},
});

const assembly = await app.getIntentAssembly({
actorUserId,
intentId: created.intentId,
});

const people = assembly.items
.filter((item) => item.kind === 'person')
.map((item) => ({
id: item.itemId,
name: item.title,
context: item.subtitle,
reason: item.reason,
strength: item.strength,
primaryAction: item.primaryAction,
target: item.target,
}));

Do not send requests from this recipe automatically. Matching is a read step. The user still chooses who to contact.

createRoom

Use this when the user asks for a group space around an intent:

Create a room for people discussing growth strategies for social apps.

First, read assembly. If the protocol already returns a relevant room, show that room before creating another one.

const assembly = await app.getIntentAssembly({
actorUserId,
intentId,
});

const existingRoom = assembly.items.find((item) => item.kind === 'room');

if (existingRoom) {
return {
kind: 'existing_room',
room: existingRoom,
};
}

Only call createCircle after the user explicitly confirms that a new room should exist.

const circle = await app.createCircle({
actorUserId,
title: 'Consumer AI growth circle',
description: 'A small room for people discussing growth strategies for social apps.',
visibility: 'invite_only',
topicTags: ['consumer-ai', 'growth', 'social-apps'],
targetSize: 6,
kickoffPrompt: 'What growth experiment should each person bring?',
cadence: {
kind: 'weekly',
days: ['thu'],
hour: 18,
minute: 0,
timezone: 'America/Argentina/Buenos_Aires',
intervalWeeks: 1,
},
metadata: {
source: 'partner.recipe.create_room',
originIntentId: intentId,
},
});

Use room creation for coordination spaces. Do not use it as a generic feed, channel, or community primitive.

requestIntro

Use this when the user picks a person and asks for an introduction.

const selectedPerson = assembly.items.find(
(item) => item.kind === 'person' && item.itemId === selectedPersonId,
);

if (!selectedPerson) {
throw new Error('Selected person is not part of this intent assembly.');
}

const intro = await app.sendRequest({
actorUserId,
intentId,
recipientUserId: selectedPerson.itemId,
metadata: {
source: 'partner.recipe.request_intro',
reasonShownToActor: selectedPerson.reason,
target: selectedPerson.target,
},
});

The receiver should see a clear private ask, not a cold DM. A chat should open only after the recipient accepts.

subscribeToIntent

Use this when the user asks the partner app to keep watching an intent.

Subscriptions are implemented with protocol events plus replay. Create one webhook for the event families your app handles:

await app.createWebhook({
targetUrl: 'https://partner.example.com/webhooks/intent',
events: [
'intent.created',
'intent.updated',
'request.sent',
'request.accepted',
'request.rejected',
'circle.created',
'chat.created',
'chat.message.sent',
],
resources: ['intent', 'request', 'circle', 'chat'],
deliveryMode: 'json',
});

When your worker starts, resume from the stored protocol cursor:

const cursor = await app.getReplayCursor();
const events = await app.replayEvents(cursor.cursor);

for (const event of events) {
await handleProtocolEvent(event);

if (typeof event.metadata.cursor === 'string') {
await app.saveReplayCursor(event.metadata.cursor);
}
}

On relevant intent events, re-read assembly and update your local view:

const refreshed = await app.getIntentAssembly({
actorUserId,
intentId,
metadata: {
source: 'partner.recipe.subscribe_to_intent',
},
});

Keep subscriptions scoped to the actor, the granted app, and the active intent. Do not notify on weak or unrelated matches just because an event arrived.

Before shipping these recipes:

  • show people and rooms as recommendations until the user chooses an action
  • require explicit confirmation before creating a circle
  • require explicit confirmation before sending an intro request
  • never open a chat before the receiver accepts
  • persist replay cursors after processing each event
  • recover missed events with replay instead of guessing downstream state
  • store app tokens and delegated grants outside client-side code
  • keep user-facing copy focused on people, rooms, and action, not implementation details