Fency.ai

Data exploration

Ask questions over a per-user catalog.

Advanced

This example asks questions over a per-user 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 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.

API routes

Each route under api/ maps to one operation in the API reference, except the app-specific catalog setup routes which live under api/setup/.

app/data-exploration/api/
  create-stream-session/          POST /v1/sessions (createStream)
  create-agent-task-session/      POST /v1/sessions (createAgentTask)
  create-conversation/            POST /v1/conversations
  list-conversations/             POST /v1/conversations/search
  list-agent-tasks/               GET  /v1/agent-tasks
  get-agent-task-response/        GET  /v1/agent-tasks/{id} + getAgentTaskResponse session
  setup/
    get-setup-status/             app catalog check (Drizzle)
    create-car-catalog/           app catalog seed (memory types + memories)

Server session routes

The example defines two session routes: one for stream sessions and one for agent task sessions. Each route is standalone so you can read a single file and see the full POST /v1/sessions call.

Create the stream session route at api/create-stream-session/route.ts:

import { NextResponse } from 'next/server'
import { getAuthorizedUserId } from '../../../auth'
import { sessionClientTokenSchema } from '../../sessionClientTokenSchema'

export async function POST() {
  const userId = await getAuthorizedUserId()
  if (!userId) {
    return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
  }

  const secretKey = process.env.FENCY_SECRET_KEY
  if (!secretKey) {
    throw new Error('FENCY_SECRET_KEY is not defined.')
  }

  const response = await fetch('https://api.fency.ai/v1/sessions', {
    method: 'POST',
    headers: {
      Authorization: `Bearer ${secretKey}`,
      'Content-Type': 'application/json',
    },
    body: JSON.stringify({ createStream: {} }),
  })
  if (!response.ok) {
    throw new Error('Failed to create Fency session.')
  }
  return NextResponse.json(sessionClientTokenSchema.parse(await response.json()))
}

Create the agent task session route at api/create-agent-task-session/route.ts:

import { NextResponse } from 'next/server'
import { z } from 'zod'
import { getAuthorizedUserId } from '../../../auth'
import { sessionClientTokenSchema } from '../../sessionClientTokenSchema'
import { getSyncedCarCatalog } from './getSyncedCarCatalog'
import { getExploreMemoriesVersionTag } from '../../versionTag'
import { getFencyConversation } from '../getFencyConversation'
import { buildExploreCarGuardRails } from './buildExploreCarGuardRails'

const bodySchema = z.object({
  conversationId: z.string(),
})

export async function POST(request: Request) {
  const userId = await getAuthorizedUserId()
  if (!userId) {
    return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
  }

  const { conversationId } = bodySchema.parse(await request.json())

  const conversation = await getFencyConversation(conversationId)
  if (!conversation.ok) {
    return NextResponse.json(
      { error: 'Conversation not found' },
      { status: conversation.status },
    )
  }

  if (conversation.data.metadata.userId !== userId) {
    return NextResponse.json(
      { error: 'Conversation does not belong to this user' },
      { status: 403 },
    )
  }

  const versionTag = getExploreMemoriesVersionTag()
  const catalog = await getSyncedCarCatalog(userId, versionTag)
  if (!catalog) {
    return NextResponse.json(
      { error: 'Create the DemoCar catalog before exploring memories.' },
      { status: 409 },
    )
  }

  const secretKey = process.env.FENCY_SECRET_KEY
  if (!secretKey) {
    throw new Error('FENCY_SECRET_KEY is not defined.')
  }

  const response = await fetch('https://api.fency.ai/v1/sessions', {
    method: 'POST',
    headers: {
      Authorization: `Bearer ${secretKey}`,
      'Content-Type': 'application/json',
    },
    body: JSON.stringify({
      createAgentTask: {
        taskType: 'EXPLORE_MEMORIES',
        conversationId,
        metadata: { userId },
        background: 'You help the user explore a catalog of cars.',
        guardRails: buildExploreCarGuardRails(
          catalog.memoryTypeId,
          catalog.versionTag,
          userId,
        ),
      },
    }),
  })
  if (!response.ok) {
    throw new Error('Failed to create Fency session.')
  }
  return NextResponse.json(sessionClientTokenSchema.parse(await response.json()))
}

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/create-agent-task-session/buildExploreCarGuardRails.ts:

import { DEMO_CAR_TAG_KEY } from '../../demoCarConstants'

export function buildExploreCarGuardRails(
  memoryTypeId: string,
  versionTag: string,
  userId: string,
) {
  return {
    memoryTypes: [
      {
        memoryTypeId,
        match: { [DEMO_CAR_TAG_KEY]: versionTag, userId },
        metadata: [
          { key: 'id', visible: false },
          { key: DEMO_CAR_TAG_KEY, 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 DEMO_CAR_TAG_KEY constant (defined in demoCarConstants.ts as 'demoCarTag') identifies the memory metadata field used for versioning. 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. List the signed-in user's conversations at api/list-conversations/route.ts:

import { NextResponse } from 'next/server'
import { getAuthorizedUserId } from '../../../auth'
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 items = await searchFencyConversations(userId)
  const conversations = items.map((item) => ({
    id: item.id,
    title: item.title ?? null,
    createdAt: item.createdAt,
  }))
  return NextResponse.json({ conversations })
}

Create a conversation at api/create-conversation/route.ts:

import { NextResponse } from 'next/server'
import { getAuthorizedUserId } from '../../../auth'
import { createFencyConversation } from './createFencyConversation'

export async function POST() {
  const userId = await getAuthorizedUserId()
  if (!userId) {
    return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
  }

  const data = await createFencyConversation(userId)
  return NextResponse.json(
    {
      conversation: {
        id: data.id,
        title: data.title ?? null,
        createdAt: data.createdAt,
      },
    },
    { status: 201 },
  )
}

Conversations are stamped with metadata.userId so the list route can filter by the signed-in user.

Agent task APIs

Reload a conversation by listing its agent tasks, then fetching the latest data-exploration response. List tasks at api/list-agent-tasks/route.ts (GET /v1/agent-tasks):

import { NextResponse } from 'next/server'
import { getAuthorizedUserId } from '../../../auth'
import { getFencyConversation } from '../getFencyConversation'
import { listFencyAgentTasks } from './listFencyAgentTasks'

export const dynamic = 'force-dynamic'

export async function GET(request: Request) {
  const userId = await getAuthorizedUserId()
  if (!userId) {
    return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
  }

  const conversationId = new URL(request.url).searchParams.get('conversationId')
  if (!conversationId) {
    throw new Error('conversationId is required')
  }

  const conversation = await getFencyConversation(conversationId)
  if (!conversation.ok) {
    return NextResponse.json(
      { error: 'Conversation not found' },
      { status: conversation.status },
    )
  }

  if (conversation.data.metadata.userId !== userId) {
    return NextResponse.json(
      { error: 'Conversation does not belong to this user' },
      { status: 403 },
    )
  }

  const tasks = await listFencyAgentTasks(conversationId)
  return NextResponse.json({
    agentTasks: (tasks.items ?? []).map((task) => ({
      id: task.id,
      taskType: task.taskType,
    })),
  })
}

Fetch one archived response at api/get-agent-task-response/route.ts. The route loads GET /v1/agent-tasks/{agentTaskId} to authorize, then creates a getAgentTaskResponse session to download the archive:

import { NextResponse } from 'next/server'
import { getAuthorizedUserId } from '../../../auth'
import { buildConversationTurnFromArchive } from './buildConversationTurnFromArchive'
import { getFencyAgentTask } from './getFencyAgentTask'
import { resolveRequestOrigin } from './resolveRequestOrigin'

export async function GET(request: Request) {
  const userId = await getAuthorizedUserId()
  if (!userId) {
    return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
  }

  const agentTaskId = new URL(request.url).searchParams.get('agentTaskId')
  if (!agentTaskId) {
    throw new Error('agentTaskId is required')
  }

  const agentTask = await getFencyAgentTask(agentTaskId)
  if (!agentTask.ok) {
    return NextResponse.json(
      { error: 'Agent task not found' },
      { status: agentTask.status },
    )
  }

  if (agentTask.data.metadata.userId !== userId) {
    return NextResponse.json(
      { error: 'Agent task does not belong to this user' },
      { status: 403 },
    )
  }

  const turn = await buildConversationTurnFromArchive(
    agentTaskId,
    resolveRequestOrigin(request),
  )
  return NextResponse.json({ turn })
}

The client composes these two routes in hooks/useConversation.ts. It picks the last EXPLORE_MEMORIES task, then loads that task's archived response:

  const loadLatestTurn = useCallback(async (conversationId: string) => {
    setIsLoadingTurn(true)
    setError(null)
    try {
      const listRes = await fetch(
        `/data-exploration/api/list-agent-tasks?conversationId=${encodeURIComponent(conversationId)}`,
      )
      if (!listRes.ok) {
        throw new Error('Failed to load conversation.')
      }
      const { agentTasks } = listAgentTasksResponseSchema.parse(
        await listRes.json(),
      )
      const latestTask = agentTasks
        .filter((task) => task.taskType === 'EXPLORE_MEMORIES')
        .at(-1)
      if (!latestTask) {
        setLatestTurn(null)
        return
      }

      const turnRes = await fetch(
        `/data-exploration/api/get-agent-task-response?agentTaskId=${encodeURIComponent(latestTask.id)}`,
      )
      if (!turnRes.ok) {
        throw new Error('Failed to load conversation.')
      }
      const { turn } = agentTaskResponseSchema.parse(await turnRes.json())
      setLatestTurn(turn)
    } catch {
      setLatestTurn(null)
      setError('Failed to load conversation.')
    } finally {
      setIsLoadingTurn(false)
    }
  }, [])

Catalog setup

Before exploring, the example seeds a DemoCar catalog into Fency memories. Those routes are app-specific (Drizzle + memory type/memory APIs) and live under api/setup/.

Check whether the catalog is ready at api/setup/get-setup-status/route.ts:

import { NextResponse } from 'next/server'
import { getAuthorizedUserId } from '../../../../auth'
import { DEMO_CAR_CATALOG_SIZE } from '../../../demoCarConstants'
import { carRepository } from '../../../db/carRepository'
import { getExploreMemoriesVersionTag } from '../../../versionTag'

export const dynamic = 'force-dynamic'

export async function GET() {
  const userId = await getAuthorizedUserId()
  if (!userId) {
    return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
  }

  const versionTag = getExploreMemoriesVersionTag()
  const synced = await carRepository.countSynced(userId, versionTag)
  return NextResponse.json({
    ready: synced >= DEMO_CAR_CATALOG_SIZE,
    syncedCars: synced,
    versionTag,
  })
}

Seed the catalog at api/setup/create-car-catalog/route.ts. hooks/useSetup.ts calls /data-exploration/api/setup/get-setup-status and /data-exploration/api/setup/create-car-catalog.

Client provider and hooks

Both session routes respond with a client token. Parse that response with a Zod schema in sessionClientTokenSchema.ts so a bad shape throws:

import { z } from 'zod'

export const sessionClientTokenSchema = z.object({
  clientToken: z.string(),
})

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'
import { sessionClientTokenSchema } from './sessionClientTokenSchema'

const publishableKey = process.env.NEXT_PUBLIC_FENCY_PUBLISHABLE_KEY
if (!publishableKey) {
  throw new Error('NEXT_PUBLIC_FENCY_PUBLISHABLE_KEY is not defined.')
}

const fency = loadFency({
  publishableKey,
})

async function fetchCreateStreamClientToken() {
  const res = await fetch('/data-exploration/api/create-stream-session', {
    method: 'POST',
  })
  if (!res.ok) {
    throw new Error('Failed to create stream session')
  }
  const { clientToken } = sessionClientTokenSchema.parse(await res.json())
  return { clientToken }
}

export default function DataExplorationPage() {
  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.

Use useAgentTasks and createAgentTask to send queries in hooks/useExploreChat.ts:

'use client'

import { useAgentTasks } from '@fencyai/react'
import { useEffect, useRef, useState } from 'react'
import { sessionClientTokenSchema } from '../sessionClientTokenSchema'
import type { LatestTurn } from './useConversation'

async function fetchCreateAgentTaskClientToken(conversationId: string) {
  const res = await fetch('/data-exploration/api/create-agent-task-session', {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
    },
    body: JSON.stringify({ conversationId }),
  })
  if (!res.ok) {
    throw new Error('Failed to create agent task session')
  }
  const { clientToken } = sessionClientTokenSchema.parse(await res.json())
  return { clientToken }
}

export function useExploreChat({
  selectedConversationId,
  latestTurn,
  onEnsureConversation,
  onFirstMessage,
}: {
  selectedConversationId: string | null
  latestTurn: LatestTurn | null
  onEnsureConversation: () => Promise<{ id: string }>
  onFirstMessage: (conversationId: string, query: string) => void
}) {
  const [liveQuery, setLiveQuery] = useState<string | null>(null)
  const [isSubmitting, setIsSubmitting] = useState(false)
  const { latest, createAgentTask } = useAgentTasks({})
  const previousConversationId = useRef(selectedConversationId)

  useEffect(() => {
    const previousId = previousConversationId.current
    previousConversationId.current = selectedConversationId
    if (previousId !== null && previousId !== selectedConversationId) {
      setLiveQuery(null)
    }
  }, [selectedConversationId])

  const liveExploreTask =
    liveQuery && latest && latest.params.type === 'ExploreMemories'
      ? latest
      : null

  async function sendQuery(text: string) {
    const hadHistory = latestTurn !== null
    setIsSubmitting(true)
    setLiveQuery(text)

    try {
      const current = await onEnsureConversation()
      if (!hadHistory) {
        onFirstMessage(current.id, text)
      }

      const response = await createAgentTask(
        {
          type: 'ExploreMemories',
          query: text,
          model: 'anthropic/claude-sonnet-4.6',
        },
        {
          fetchCreateAgentTaskClientToken: () =>
            fetchCreateAgentTaskClientToken(current.id),
        },
      )

      if (response.type !== 'success') {
        throw new Error(response.error.message)
      }
      if (response.response.taskType !== 'ExploreMemories') {
        throw new Error('Unexpected ExploreMemories outcome.')
      }
    } finally {
      setIsSubmitting(false)
    }
  }

  return {
    isSubmitting,
    displayedQuery: liveQuery ?? latestTurn?.query,
    displayedTask: liveExploreTask ?? latestTurn?.agentTask ?? null,
    sendQuery,
  }
}

components/ChatPane.tsx renders the query, AgentTaskProgress, and the composer. The session route includes the conversationId to maintain chat history across turns.

Database tables and repositories

The example stores the car catalog in PostgreSQL using Drizzle ORM. Each table lives in its own file. Create db/memoryTypeTable.ts:

import { pgTable, text, timestamp, uuid } from 'drizzle-orm/pg-core'

export const memoryTypeTable = 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(),
})

Create db/carTable.ts:

import {
  integer,
  pgTable,
  text,
  timestamp,
  uniqueIndex,
  uuid,
} from 'drizzle-orm/pg-core'

export const carTable = 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.

Each table has its own repository. Create db/carRepository.ts for explore_memories_cars:

import 'server-only'

import { and, eq, isNotNull, isNull, ne } from 'drizzle-orm'
import { db } from './client'
import { carTable } from './carTable'

type NewCar = {
  userId: string
  identity: string
  versionTag: string
  make: string
  model: string
  year: number
  color: string
  priceUsd: number
  mileageKm: number
  fuelType: string
  transmission: string
  bodyStyle: string
  horsepower: number
  updatedAt: Date
}

export const carRepository = {
  async listByUser(userId: string, versionTag: string) {
    return db
      .select()
      .from(carTable)
      .where(
        and(
          eq(carTable.userId, userId),
          eq(carTable.versionTag, versionTag),
        ),
      )
  },

  async countSynced(userId: string, versionTag: string) {
    const rows = await db
      .select({ id: carTable.id })
      .from(carTable)
      .where(
        and(
          eq(carTable.userId, userId),
          eq(carTable.versionTag, versionTag),
          isNotNull(carTable.fencyMemoryId),
        ),
      )
    return rows.length
  },

  async deleteStale(userId: string, versionTag: string) {
    await db
      .delete(carTable)
      .where(
        and(
          eq(carTable.userId, userId),
          ne(carTable.versionTag, versionTag),
        ),
      )

    await db
      .delete(carTable)
      .where(
        and(
          eq(carTable.userId, userId),
          eq(carTable.versionTag, versionTag),
          isNull(carTable.fencyMemoryId),
        ),
      )
  },

  async insertMany(cars: NewCar[]) {
    if (cars.length === 0) {
      return
    }

    await db
      .insert(carTable)
      .values(cars)
      .onConflictDoNothing({
        target: [
          carTable.userId,
          carTable.identity,
          carTable.versionTag,
        ],
      })
  },

  async touchUnsynced(userId: string, versionTag: string, updatedAt: Date) {
    await db
      .update(carTable)
      .set({ updatedAt })
      .where(
        and(
          eq(carTable.userId, userId),
          eq(carTable.versionTag, versionTag),
          isNull(carTable.fencyMemoryId),
        ),
      )
  },

  async assignMemoryIds(
    mappings: Array<{
      identity: string
      versionTag: string
      fencyMemoryId: string
    }>,
  ) {
    for (const mapping of mappings) {
      await db
        .update(carTable)
        .set({ fencyMemoryId: mapping.fencyMemoryId })
        .where(
          and(
            eq(carTable.identity, mapping.identity),
            eq(carTable.versionTag, mapping.versionTag),
          ),
        )
    }
  },
}

Create db/memoryTypeRepository.ts for explore_memories_memory_types:

import 'server-only'

import { eq } from 'drizzle-orm'
import { db } from './client'
import { memoryTypeTable } from './memoryTypeTable'

export const memoryTypeRepository = {
  async findByName(name: string) {
    const [row] = await db
      .select()
      .from(memoryTypeTable)
      .where(eq(memoryTypeTable.name, name))
      .limit(1)
    return row ?? null
  },

  async save(name: string, fencyMemoryTypeId: string) {
    const existing = await memoryTypeRepository.findByName(name)
    if (existing) {
      if (existing.fencyMemoryTypeId !== fencyMemoryTypeId) {
        await db
          .update(memoryTypeTable)
          .set({ fencyMemoryTypeId })
          .where(eq(memoryTypeTable.id, existing.id))
      }
      return { ...existing, fencyMemoryTypeId }
    }

    const [row] = await db
      .insert(memoryTypeTable)
      .values({
        name,
        fencyMemoryTypeId,
      })
      .returning()
    return row
  },
}

Shared catalog constants live at the feature root in demoCarConstants.ts:

export const DEMO_CAR_MEMORY_TYPE_NAME = 'DemoCar'
export const DEMO_CAR_CATALOG_SIZE = 100
export const DEMO_CAR_TAG_KEY = 'demoCarTag'

The DEMO_CAR_TAG_KEY is used in memory metadata to version the catalog and isolate datasets between development and production. The agent-task session route composes both repositories in api/create-agent-task-session/getSyncedCarCatalog.ts before creating a session.

Version tags

The example uses a version tag to isolate catalog data. Create versionTag.ts in the example folder:

import 'server-only'

export function getExploreMemoriesVersionTag() {
  const value = process.env.EXPLORE_MEMORIES_VERSION_TAG?.trim()
  if (!value) {
    throw new Error('EXPLORE_MEMORIES_VERSION_TAG 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/data-exploration. Sign up from the header, click Seed catalog to sync the demo cars, then start a conversation to explore the catalog.

On this page