Fency.ai

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.

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 install

Copy .env.example to .env.local and add your Fency keys and database URL:

cp .env.example .env.local

Set 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_v1

Start PostgreSQL and apply migrations:

docker compose up -d
npm run db:migrate

Initialize Clerk authentication (provisions a development app):

npx -y clerk@latest init --keyless -y

Start the development server:

npm run dev

Open 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.

On this page