Explore memories
Memory-grounded chat over a catalog with guard rails and conversations.
This example demonstrates multi-turn memory-grounded conversations where an AI assistant helps users explore a car catalog. The example uses Fency memories to ground the agent's knowledge, guard rails to control which memories are accessible, and conversations to maintain chat history across turns.
- Live demo: react.fency.ai/explore-memories
- GitHub folder: fency-react-examples/app/explore-memories
Prerequisites
You need a publishable key for the React SDK and a secret key for your server routes. This example uses sessions and client tokens to authenticate the client.
The example uses Clerk for user authentication. That is part of the app shell and not specific to Fency.
This example also requires a PostgreSQL database to store the car catalog and track which cars have been synced to Fency.
Server session routes
The example defines two session routes: one for stream sessions and one for agent task sessions.
Create a shared helper api/createFencySession.ts:
import 'server-only'
import { NextResponse } from 'next/server'
export async function createFencySession(body: Record<string, unknown>) {
const secretKey = process.env.FENCY_SECRET_KEY
if (!secretKey) {
throw new Error('FENCY_SECRET_KEY is not defined.')
}
try {
const response = await fetch('https://api.fency.ai/v1/sessions', {
method: 'POST',
headers: {
Authorization: `Bearer ${secretKey}`,
'Content-Type': 'application/json',
},
body: JSON.stringify(body),
})
const data = await response.json()
return NextResponse.json(data, { status: response.status })
} catch (error) {
console.error('Fency session API error:', error)
return NextResponse.json(
{ error: 'Failed to create session' },
{ status: 502 },
)
}
}Create the stream session route at api/stream-session/route.ts:
import { NextResponse } from 'next/server'
import { getAuthorizedUserId } from '../../../auth'
import { createFencySession } from '../createFencySession'
export async function POST() {
const userId = await getAuthorizedUserId()
if (!userId) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
return createFencySession({ createStream: {} })
}Create the agent task session route at api/agent-task-session/route.ts:
import { NextResponse } from 'next/server'
import { getAuthorizedUserId } from '../../../auth'
import { getExampleVersionTag } from '../../../exampleVersionTag'
import { getSyncedCarCatalog } from '../../db/queries'
import { createFencySession } from '../createFencySession'
import { getFencyConversation } from '../getFencyConversation'
import { buildExploreCarGuardRails } from './buildExploreCarGuardRails'
export async function POST(request: Request) {
const userId = await getAuthorizedUserId()
if (!userId) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const body = (await request.json().catch(() => ({}))) as {
conversationId?: string
}
if (!body.conversationId) {
return NextResponse.json(
{ error: 'conversationId is required' },
{ status: 400 },
)
}
const conversation = await getFencyConversation(body.conversationId)
if (!conversation.ok) {
return NextResponse.json(conversation.data, { status: conversation.status })
}
if (conversation.data.metadata?.userId !== userId) {
return NextResponse.json(
{ error: 'Conversation does not belong to this user' },
{ status: 403 },
)
}
const versionTag = getExampleVersionTag('explore-memories')
const catalog = await getSyncedCarCatalog(userId, versionTag)
if (!catalog) {
return NextResponse.json(
{ error: 'Create the DemoCar catalog before exploring memories.' },
{ status: 409 },
)
}
return createFencySession({
createAgentTask: {
taskType: 'EXPLORE_MEMORIES',
conversationId: body.conversationId,
metadata: { userId },
background: 'You help the user explore a catalog of cars.',
guardRails: buildExploreCarGuardRails(
catalog.memoryTypeId,
catalog.versionTag,
userId,
),
},
})
}The route verifies the user owns the conversation, loads the synced car catalog, and creates a session with guard rails that restrict memory access to the user's catalog.
Guard rails
Guard rails control which memories the agent can access. Create api/agent-task-session/buildExploreCarGuardRails.ts:
export function buildExploreCarGuardRails(
memoryTypeId: string,
versionTag: string,
userId: string,
) {
return {
memoryTypes: [
{
memoryTypeId,
match: { version_tag: versionTag, userId },
metadata: [
{ key: 'id', visible: false },
{ key: 'version_tag', visible: false },
{ key: 'userId', visible: false },
{ key: 'catalog_id', visible: true, description: 'Catalog identity' },
{ key: 'make', visible: true, description: 'Vehicle make' },
{ key: 'model', visible: true, description: 'Vehicle model' },
{ key: 'year', visible: true, description: 'Model year' },
{ key: 'color', visible: true, description: 'Exterior color' },
{
key: 'price_usd',
visible: true,
description: 'List price in US dollars',
},
{
key: 'mileage_km',
visible: true,
description: 'Odometer reading in kilometers',
},
{ key: 'fuel_type', visible: true, description: 'Fuel type' },
{ key: 'transmission', visible: true, description: 'Transmission' },
{ key: 'body_style', visible: true, description: 'Body style' },
{ key: 'horsepower', visible: true, description: 'Horsepower' },
],
},
],
}
}The match object filters memories to those belonging to the user's version tag. The metadata array specifies which fields are visible to the agent and their descriptions.
Conversation APIs
The example uses Fency conversations to maintain chat history across turns. Create conversation routes at api/conversation/route.ts:
import { NextResponse } from 'next/server'
import { getAuthorizedUserId } from '../../../auth'
import { createFencyConversation } from './createFencyConversation'
import { searchFencyConversations } from './searchFencyConversations'
export const dynamic = 'force-dynamic'
export async function GET() {
const userId = await getAuthorizedUserId()
if (!userId) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const { ok, status, data } = await searchFencyConversations(userId)
if (!ok) {
return NextResponse.json(data, { status })
}
const conversations = (data.items ?? []).map((item) => ({
id: item.id,
title: item.title ?? null,
createdAt: item.createdAt,
}))
return NextResponse.json({ conversations })
}
export async function POST() {
const userId = await getAuthorizedUserId()
if (!userId) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const { ok, status, data } = await createFencyConversation({
metadata: { userId },
})
if (!ok || typeof data.id !== 'string') {
return NextResponse.json(data, { status })
}
return NextResponse.json(
{
conversation: {
id: data.id,
title: data.title ?? null,
createdAt: data.createdAt,
},
},
{ status: 201 },
)
}Conversations are stamped with metadata.userId so the route can filter by the signed-in user.
Client provider and hooks
Load the Fency client and wrap your component tree with FencyProvider in page.tsx:
'use client'
import { loadFency } from '@fencyai/js'
import { FencyProvider } from '@fencyai/react'
import { Explorer } from './components/Explorer'
import { SetupGate } from './components/SetupGate'
const fency = loadFency({
publishableKey: process.env.NEXT_PUBLIC_FENCY_PUBLISHABLE_KEY!,
})
async function fetchCreateStreamClientToken() {
const res = await fetch('/explore-memories/api/stream-session', {
method: 'POST',
})
if (!res.ok) {
throw new Error('Failed to create stream session')
}
const data = (await res.json()) as { clientToken?: string }
if (!data.clientToken) {
throw new Error('No clientToken in session response')
}
return { clientToken: data.clientToken }
}
export default function ExploreMemoriesPage() {
return (
<div className="flex min-h-0 flex-1 flex-col overflow-hidden">
<SetupGate>
<FencyProvider
fency={fency}
fetchCreateStreamClientToken={fetchCreateStreamClientToken}
>
<Explorer />
</FencyProvider>
</SetupGate>
</div>
)
}The SetupGate component ensures the memory catalog is synced before allowing the user to explore.
Database schema
The example stores the car catalog in PostgreSQL using Drizzle ORM. Create db/schema.ts:
import {
integer,
pgTable,
text,
timestamp,
uniqueIndex,
uuid,
} from 'drizzle-orm/pg-core'
export const exploreMemoriesMemoryTypes = pgTable(
'explore_memories_memory_types',
{
id: uuid('id').defaultRandom().primaryKey(),
name: text('name').notNull().unique(),
fencyMemoryTypeId: text('fency_memory_type_id').notNull().unique(),
createdAt: timestamp('created_at', { withTimezone: true })
.defaultNow()
.notNull(),
},
)
export const exploreMemoriesCars = pgTable(
'explore_memories_cars',
{
id: uuid('id').defaultRandom().primaryKey(),
userId: text('user_id').notNull(),
identity: text('identity').notNull(),
versionTag: text('version_tag').notNull(),
make: text('make').notNull(),
model: text('model').notNull(),
year: integer('year').notNull(),
color: text('color').notNull(),
priceUsd: integer('price_usd').notNull(),
mileageKm: integer('mileage_km').notNull(),
fuelType: text('fuel_type').notNull(),
transmission: text('transmission').notNull(),
bodyStyle: text('body_style').notNull(),
horsepower: integer('horsepower').notNull(),
fencyMemoryId: text('fency_memory_id'),
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull(),
createdAt: timestamp('created_at', { withTimezone: true })
.defaultNow()
.notNull(),
},
(table) => [
uniqueIndex('explore_memories_cars_user_identity_tag_idx').on(
table.userId,
table.identity,
table.versionTag,
),
],
)The fencyMemoryId column stores the ID returned by Fency after syncing each car.
Version tags
The example uses a version tag to isolate catalog data. Create exampleVersionTag.ts at the app root:
import 'server-only'
const EXAMPLE_VERSION_TAG_ENV = {
'explore-memories': 'EXPLORE_MEMORIES_VERSION_TAG',
} as const
export type VersionedExample = keyof typeof EXAMPLE_VERSION_TAG_ENV
export function getExampleVersionTag(example: VersionedExample): string {
const envName = EXAMPLE_VERSION_TAG_ENV[example]
const value = process.env[envName]?.trim()
if (!value) {
throw new Error(`${envName} is not defined.`)
}
return value
}Use different version tags in development and production to isolate datasets.
Running locally
Clone the repository and install dependencies:
git clone https://github.com/fencyai/fency-react-examples.git
cd fency-react-examples
npm installCopy .env.example to .env.local and add your Fency keys and database URL:
cp .env.example .env.localSet the keys in .env.local:
FENCY_SECRET_KEY=sk_...
NEXT_PUBLIC_FENCY_PUBLISHABLE_KEY=pk_...
DATABASE_URL=postgresql://fency:fency@127.0.0.1:5433/fency_react_examples
EXPLORE_MEMORIES_VERSION_TAG=explore_memories_local_v1Start PostgreSQL and apply migrations:
docker compose up -d
npm run db:migrateInitialize Clerk authentication (provisions a development app):
npx -y clerk@latest init --keyless -yStart the development server:
npm run devOpen http://localhost:3000/explore-memories. Sign up from the header, click Seed catalog to sync the demo cars, then start a conversation to explore the catalog.