# Agent tasks (/docs/agent-tasks)
An agent task is a single invocation of the agent, performing a task defined by the user through combined [session](/docs/sessions) params and the params received from the webapp during invocation.
Here are the agent tasks you can start today:
| Task type | Purpose |
| --- | --- |
| `STREAMING_CHAT_COMPLETION` | Stream a chat completion in the browser |
| `STRUCTURED_CHAT_COMPLETION` | Extract structured data from a chat completion |
| `MEMORY_CHAT` | Chat over `SEMANTIC` memories |
| `MEMORY_SEARCH` | Semantic search (RAG) over `SEMANTIC` memories |
| `EXPLORE_MEMORIES` | A task made for data analysis. The agent explores `METADATA` and `SEMANTIC` memories together. It runs code and can perform advanced analytics, generate reports, and render charts for the data, giving you a powerful tool for data analytics directly in your webapp. |
| `EXPLORE_PRODUCT` | A task made for building webapp assistants where the agent can call actions and render components defined by the user (in the browser). It can also perform simple semantic searches over memories if you need to connect a knowledge base for the agent. |
## How it works [#how-it-works]
1. **Server creates a session:** Your backend calls `POST /v1/sessions` with a `createAgentTask` body for that `taskType`, plus any [guardrails](/docs/sessions/guardrails) or [allowed actions](/docs/sessions/allowed-actions). It returns a `clientToken` to the webapp.
2. **SDK starts the task:** `createAgentTask` in the React SDK uses that token to create the agent task.
3. **Stream delivers progress:** A `createStream` session lets the browser listen for updates.
4. **Server can inspect the task:** List or get the task with a secret key via [Agent tasks](/api/agent-tasks).
```js
const { createAgentTask } = useAgentTasks({})
const response = await createAgentTask(
{
type: 'StreamingChatCompletion',
messages: [{ role: 'USER', content: 'Hello' }],
model: 'anthropic/claude-sonnet-4.6',
},
{
fetchCreateAgentTaskClientToken: async () => {
const res = await fetch('/api/agent-task-session', { method: 'POST' })
if (!res.ok) {
throw new Error('Failed to create agent task session')
}
const data = await res.json()
if (!data.clientToken) {
throw new Error('No clientToken in session response')
}
return { clientToken: data.clientToken }
},
},
)
```
## Examples [#examples]
These examples show how you initialize each task in the React SDK. Combine them with a server-created [session](/docs/sessions) that returns a `clientToken` for that `taskType`. The SDK cannot start a task without that token.
**`StreamingChatCompletion`**
```js
{
type: 'StreamingChatCompletion',
messages: [{ role: 'USER', content: 'Hello' }],
model: 'anthropic/claude-sonnet-4.6',
}
```
**`StructuredChatCompletion`**
```js
{
type: 'StructuredChatCompletion',
messages: [{ role: 'USER', content: 'Jane lives in Oslo.' }],
model: 'anthropic/claude-sonnet-4.6',
jsonSchema: '{"type":"object","properties":{"name":{"type":"string"}}}',
}
```
`MemoryChat` and `MemorySearch` accept `chunkLimit` (1-100). It is applied after [guardrails](/docs/sessions/guardrails) filter the corpus, so you do not need to raise it to compensate for memories the rails would discard.
**`MemoryChat`**
```js
{
type: 'MemoryChat',
messages: [{ role: 'USER', content: 'What does the Acme MSA cover?' }],
model: 'anthropic/claude-sonnet-4.6',
language: 'en',
chunkLimit: 20,
}
```
**`MemorySearch`**
```js
{
type: 'MemorySearch',
query: 'termination notice period',
model: 'anthropic/claude-sonnet-4.6',
language: 'en',
chunkLimit: 20,
}
```
**`ExploreMemories`**
```js
{
type: 'ExploreMemories',
query: 'Which contracts renew this quarter?',
language: 'en',
}
```
**`ExploreProduct`**
```js
{
type: 'ExploreProduct',
query: 'Apply the open tickets filter',
}
```
See [Sessions](/docs/sessions) for the matching session body, and the [integration examples](/docs/integration) for [streaming response](/docs/integration/streaming-response), [JSON response](/docs/integration/json-response), [basic chat](/docs/integration/basic-chat), [JSON extraction](/docs/integration/json-extraction), and [data exploration](/docs/integration/data-exploration).
# Getting started (/docs/getting-started)
Fency.ai provides seamless LLM integration for React webapps. Get started in minutes with our React SDK and start building AI-powered features.
## Quick start [#quick-start]
To get started with Fency.ai in your React application:
1. **Log in:** [Log in to your Fency.ai account](https://app.fency.ai)
2. **Obtain publishable and secret keys:** Generate your first [publishable key](/docs/publishable-keys) and [secret key](/docs/secret-keys) in the dashboard
3. **Install the React SDK:**
```bash
npm install --save @fencyai/js @fencyai/react
```
4. **Set up endpoints:** Create backend endpoints for stream sessions and agent task sessions. See [Sessions](/docs/sessions).
Then provide your [publishable key](/docs/publishable-keys) to `loadFency` and wrap your app in `FencyProvider`:
```tsx
import { loadFency } from '@fencyai/js'
import { FencyProvider } from '@fencyai/react'
const fency = loadFency({ publishableKey: 'pk_your_publishable_key' })
const fetchCreateStreamClientToken = async () => {
const res = await fetch('/api/stream-client-token', { method: 'POST' })
if (!res.ok) throw new Error('Failed to create stream client token')
const data = await res.json()
if (!data.clientToken) throw new Error('No clientToken in response')
return { clientToken: data.clientToken }
}
export default function App() {
return (
)
}
```
Mount `FencyProvider` once in the root layout. Every `useAgentTasks` hook and every agent task under it shares the same streams. Do not add a provider per page, component, or task. `fetchCreateStreamClientToken` is not called per task: the provider reuses a stream for about 5 minutes, then mints a new one.
For a fully functioning setup, see the [integration examples](/docs/integration) and the [fency-react-examples](https://github.com/fencyai/fency-react-examples) repository.
## Supported models [#supported-models]
Fency.ai supports leading LLM models from Anthropic, Google, and OpenAI:
- **Anthropic:** Claude Haiku 4.5, Claude Sonnet 4.5, Claude Opus 4.5, Claude Sonnet 4.6, Claude Opus 4.6
- **Google:** Gemini 2.5 Flash, Gemini 2.5 Flash Lite, Gemini 2.5 Pro
- **OpenAI:** GPT-4.1-nano, GPT-4.1-mini, GPT-4.1, GPT-5-mini, GPT-5-nano
# What is Fency (/docs)
Fency is a developer-friendly framework for getting AI features into production. Your backend syncs data to Fency and controls what the AI can access. Your React webapp uses the Fency SDK to run AI tasks and stream results to your users.
## What you need to get started [#what-you-need-to-get-started]
1. A [Fency account](https://app.fency.ai) with a [publishable key](/docs/publishable-keys) and a [secret key](/docs/secret-keys)
2. A backend that can call the Fency API (any language)
3. A React webapp with the `@fencyai/react` SDK installed
Follow the [getting started guide](/docs/getting-started) for the full setup.
## Before you integrate [#before-you-integrate]
A few concepts are worth understanding first:
- **[Memories](/docs/memories):** How your data gets into Fency. Your backend creates memories that the AI can use as context.
- **[Sessions](/docs/sessions):** How access is controlled. Your backend creates a session that limits what the AI can see and do, and hands your webapp a short-lived client token.
- **[Agent tasks](/docs/agent-tasks):** The work your webapp starts with a client token, such as chat or exploring memories.
Once these make sense, the [integration examples](/docs/integration) show complete, working setups.
# Basic chat (/docs/integration/basic-chat)
This example is a multi-turn chat. Each reply streams in as the model generates it.
- **Live demo**: [react.fency.ai/basic-chat](https://react.fency.ai/basic-chat)
- **GitHub folder**: [fency-react-examples/app/basic-chat](https://github.com/fencyai/fency-react-examples/tree/main/app/basic-chat)
## Prerequisites [#prerequisites]
You need a [publishable key](/docs/publishable-keys) for the React SDK and a [secret key](/docs/secret-keys) for your server routes. This example uses [sessions](/docs/sessions) to authenticate the client.
The example uses Clerk for user authentication. That is part of the app shell and not specific to Fency.
## Server session routes [#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`:
```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`:
```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({
createAgentTask: {
taskType: 'STREAMING_CHAT_COMPLETION',
metadata: { userId },
},
}),
})
if (!response.ok) {
throw new Error('Failed to create Fency session.')
}
return NextResponse.json(sessionClientTokenSchema.parse(await response.json()))
}
```
## Client provider and hooks [#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:
```ts
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`:
This example mounts `FencyProvider` on its own page because every example in this repo is standalone. In your app, mount `FencyProvider` once in the root layout. Every `useAgentTasks` hook and every agent task under it shares the same streams. Do not add a provider per page, component, or task.
```tsx
'use client'
import { loadFency } from '@fencyai/js'
import { FencyProvider } from '@fencyai/react'
import { Chat } from './components/Chat'
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('/basic-chat/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 BasicChatPage() {
return (
)
}
```
Use `useAgentTasks` and `createAgentTask` to send messages and pair each user
message with its streaming task in `hooks/useBasicChat.ts`:
```ts
'use client'
import { useAgentTasks, type AgentTask } from '@fencyai/react'
import { useState } from 'react'
import type { ChatMessage } from '../ChatMessage'
import { sessionClientTokenSchema } from '../sessionClientTokenSchema'
export type Turn = {
userMessage: ChatMessage
agentTask?: AgentTask
}
async function fetchCreateAgentTaskClientToken() {
const res = await fetch(
'/basic-chat/api/create-agent-task-session',
{ method: 'POST' },
)
if (!res.ok) {
throw new Error('Failed to create agent task session')
}
const { clientToken } = sessionClientTokenSchema.parse(await res.json())
return { clientToken }
}
export function useBasicChat() {
const [messages, setMessages] = useState([])
const [isSubmitting, setIsSubmitting] = useState(false)
const { agentTasks, createAgentTask } = useAgentTasks({})
const streamingTasks = agentTasks.filter(
(task) => task.params.type === 'StreamingChatCompletion',
)
const turns: Turn[] = messages
.filter((message) => message.role === 'USER')
.map((userMessage, index) => ({
userMessage,
agentTask: streamingTasks[index],
}))
async function sendMessage(text: string) {
setIsSubmitting(true)
const nextMessages: ChatMessage[] = [
...messages,
{ role: 'USER', content: text },
]
setMessages(nextMessages)
try {
const response = await createAgentTask(
{
type: 'StreamingChatCompletion',
messages: nextMessages,
model: 'anthropic/claude-sonnet-4.6',
},
{ fetchCreateAgentTaskClientToken },
)
if (response.type !== 'success') {
throw new Error(response.error.message)
}
if (response.response.taskType !== 'StreamingChatCompletion') {
throw new Error('Unexpected StreamingChatCompletion outcome.')
}
const assistant = response.response.response.messages.at(-1)
if (assistant?.role !== 'ASSISTANT') {
throw new Error('StreamingChatCompletion did not return an assistant message.')
}
setMessages([
...nextMessages,
{ role: 'ASSISTANT', content: assistant.content },
])
} finally {
setIsSubmitting(false)
}
}
return { turns, isSubmitting, sendMessage }
}
```
Render each turn with `AgentTaskProgress` in `components/ChatTurn.tsx`:
```tsx
import { Alert } from '@mantine/core'
import { AgentTaskProgress } from '@fencyai/react'
import type { Turn } from '../hooks/useBasicChat'
import { Bubble } from './Bubble'
export function ChatTurn({ turn }: { turn: Turn }) {
const { userMessage, agentTask } = turn
return (
{agentTask?.error ? (
{agentTask.error.message}
) : agentTask ? (
) : null}
)
}
```
The `AgentTaskProgress` component displays the streaming text as it arrives.
`components/Chat.tsx` maps the hook's `turns` to `ChatTurn` and wires
`sendMessage` to the composer form in `components/ChatComposer.tsx`.
## Running locally [#running-locally]
Clone the repository and install dependencies:
```bash
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:
```bash
cp .env.example .env.local
```
Set the keys in `.env.local`:
```
FENCY_SECRET_KEY=sk_...
NEXT_PUBLIC_FENCY_PUBLISHABLE_KEY=pk_...
```
Initialize Clerk authentication (provisions a development app):
```bash
npx -y clerk@latest init --keyless -y
```
Start the development server:
```bash
npm run dev
```
Open [http://localhost:3000/basic-chat](http://localhost:3000/basic-chat). Sign up from the header, then send a message to see tokens stream in real time.
# Data exploration (/docs/integration/data-exploration)
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.
- **Live demo**: [react.fency.ai/data-exploration](https://react.fency.ai/data-exploration)
- **GitHub folder**: [fency-react-examples/app/data-exploration](https://github.com/fencyai/fency-react-examples/tree/main/app/data-exploration)
## Prerequisites [#prerequisites]
You need a [publishable key](/docs/publishable-keys) for the React SDK and a [secret key](/docs/secret-keys) for your server routes. This example uses [sessions](/docs/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 [#api-routes]
Each route under `api/` maps to one operation in the [API reference](/api), 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 [#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`:
```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`:
```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]
Guard rails control which memories the agent can access. Create `api/create-agent-task-session/buildExploreCarGuardRails.ts`:
```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 [#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`:
```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`:
```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 [#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`):
```ts
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:
```ts
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:
```ts
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 [#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`:
```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 [#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:
```ts
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`:
This example mounts `FencyProvider` on its own page because every example in this repo is standalone. In your app, mount `FencyProvider` once in the root layout. Every `useAgentTasks` hook and every agent task under it shares the same streams. Do not add a provider per page, component, or task.
```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 (
)
}
```
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`:
```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(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 [#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`:
```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`:
```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`:
```ts
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`:
```ts
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`:
```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 [#version-tags]
The example uses a version tag to isolate catalog data. Create `versionTag.ts` in the example folder:
```ts
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 [#running-locally]
Clone the repository and install dependencies:
```bash
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:
```bash
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:
```bash
docker compose up -d
npm run db:migrate
```
Initialize Clerk authentication (provisions a development app):
```bash
npx -y clerk@latest init --keyless -y
```
Start the development server:
```bash
npm run dev
```
Open [http://localhost:3000/data-exploration](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.
# Document JSON extraction (/docs/integration/document-json-extraction)
This example uploads a PDF as a FILE memory, waits until Fency has indexed it, then extracts user-defined data points. Each data point is a MemorySearch query over that one document. A StructuredChatCompletion task then merges the excerpts into typed JSON (`string`, `string list`, `boolean`, or `number`).
- **Live demo**: [react.fency.ai/document-json-extraction](https://react.fency.ai/document-json-extraction)
- **GitHub folder**: [fency-react-examples/app/document-json-extraction](https://github.com/fencyai/fency-react-examples/tree/main/app/document-json-extraction)
## Prerequisites [#prerequisites]
You need a [publishable key](/docs/publishable-keys) for the React SDK and a [secret key](/docs/secret-keys) for your server routes. This example uses [sessions](/docs/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 persist uploaded documents and the SEMANTIC memory type id. After each upload, Fency indexes the PDF asynchronously and sends a [`memory.updated`](/api/events#memoryupdated) [webhook](/api/webhooks). Register the webhook in the dashboard and store the generated secret as `DOCUMENT_ANALYSIS_WEBHOOK_SECRET`. Fency cannot reach `localhost`, so expose the app with a tunnel when you run locally.
## API routes [#api-routes]
Each route under `api/` maps to one operation in the [API reference](/api), except the webhook and document-status routes which persist Fency events in Postgres.
```
app/document-json-extraction/api/
create-stream-session/ POST /v1/sessions (createStream)
create-memory-search-agent-task-session/ POST /v1/sessions (createAgentTask MEMORY_SEARCH)
create-structured-agent-task-session/ POST /v1/sessions (createAgentTask STRUCTURED_CHAT_COMPLETION)
upload-document/ POST /v1/memories + POST /v1/memories/{id}/uploads + S3
fency-webhook/ user.test + memory.updated (signature verification)
get-document-status/ app status check (Drizzle)
list-documents/ app document list (Drizzle)
```
## Upload a PDF as a FILE memory [#upload-a-pdf-as-a-file-memory]
The upload route creates a SEMANTIC memory type if needed, creates an empty FILE memory, asks Fency for a presigned S3 POST, and uploads the PDF. See [Uploading files for memories](/api/memory-uploads/uploading-files).
Create a SEMANTIC memory type at `api/upload-document/createFencyMemoryType.ts`:
```ts
import 'server-only'
import { z } from 'zod'
import { fencyJson } from './fencyJson'
const createdMemoryTypeSchema = z.object({
id: z.string().optional(),
error: z.object({ message: z.string().optional() }).optional(),
})
export async function createFencyMemoryType(body: {
name: string
description: string
type: 'SEMANTIC'
}) {
return fencyJson('/v1/memory-types', createdMemoryTypeSchema, {
method: 'POST',
body: JSON.stringify(body),
})
}
```
Create the empty FILE memory at `api/upload-document/createFencyFileMemory.ts`. The response field is `id`:
```ts
import 'server-only'
import { z } from 'zod'
import { fencyJson } from './fencyJson'
const createdMemorySchema = z.object({
id: z.string(),
})
export async function createFencyFileMemory(body: {
memoryTypeId: string
title: string
metadata: Record
}) {
const { ok, status, data } = await fencyJson(
'/v1/memories',
createdMemorySchema,
{
method: 'POST',
body: JSON.stringify({
memoryTypeId: body.memoryTypeId,
sourceType: 'FILE',
title: body.title,
metadata: body.metadata,
}),
},
)
if (!ok) {
throw new Error(`Failed to create file memory (${status}).`)
}
return data
}
```
Get the presigned upload at `api/upload-document/createFencyMemoryUpload.ts`:
```ts
import 'server-only'
import { z } from 'zod'
import { fencyJson } from './fencyJson'
const awsS3PostRequestSchema = z.object({
amzDate: z.string(),
amzSignature: z.string(),
amzAlgorithm: z.string(),
amzCredential: z.string(),
policy: z.string(),
key: z.string(),
uploadUrl: z.string(),
sessionToken: z.string(),
})
const createdUploadSchema = z.object({
awsS3PostRequest: awsS3PostRequestSchema,
})
export async function createFencyMemoryUpload(
memoryId: string,
body: {
fileName: string
fileSize: number
mimeType: string
},
) {
const { ok, status, data } = await fencyJson(
`/v1/memories/${memoryId}/uploads`,
createdUploadSchema,
{
method: 'POST',
body: JSON.stringify(body),
},
)
if (!ok) {
throw new Error(`Failed to create memory upload (${status}).`)
}
return data.awsS3PostRequest
}
```
Post the file to S3 at `api/upload-document/uploadFileToS3.ts`. The `file` field must be last:
```ts
import 'server-only'
export async function uploadFileToS3(
awsS3PostRequest: {
key: string
policy: string
amzAlgorithm: string
amzCredential: string
amzDate: string
amzSignature: string
sessionToken: string
uploadUrl: string
},
file: File,
) {
const formData = new FormData()
formData.append('key', awsS3PostRequest.key)
formData.append('policy', awsS3PostRequest.policy)
formData.append('x-amz-algorithm', awsS3PostRequest.amzAlgorithm)
formData.append('x-amz-credential', awsS3PostRequest.amzCredential)
formData.append('x-amz-date', awsS3PostRequest.amzDate)
formData.append('x-amz-signature', awsS3PostRequest.amzSignature)
formData.append('x-amz-security-token', awsS3PostRequest.sessionToken)
formData.append('file', file)
const response = await fetch(awsS3PostRequest.uploadUrl, {
method: 'POST',
body: formData,
})
if (!response.ok) {
throw new Error(`Failed to upload file to S3 (${response.status}).`)
}
}
```
The upload route at `api/upload-document/route.ts` wires the three calls together and stores the document as `EMPTY` until the webhook arrives:
```ts
import { NextResponse } from 'next/server'
import { getAuthorizedUserId } from '../../../auth'
import {
DOCUMENT_TAG_KEY,
DOCUMENT_TAG_VALUE,
} from '../../documentAnalysisConstants'
import { documentRepository } from '../../db/documentRepository'
import { createFencyFileMemory } from './createFencyFileMemory'
import { createFencyMemoryUpload } from './createFencyMemoryUpload'
import { ensureDocumentMemoryType } from './ensureDocumentMemoryType'
import { uploadFileToS3 } from './uploadFileToS3'
export async function POST(request: Request) {
const userId = await getAuthorizedUserId()
if (!userId) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const formData = await request.formData()
const file = formData.get('file')
if (!(file instanceof File)) {
throw new Error('Expected a file field named file.')
}
if (file.type !== 'application/pdf') {
return NextResponse.json(
{ error: 'Upload a PDF file.' },
{ status: 400 },
)
}
const memoryTypeId = await ensureDocumentMemoryType()
const memory = await createFencyFileMemory({
memoryTypeId,
title: file.name,
metadata: {
userId,
[DOCUMENT_TAG_KEY]: DOCUMENT_TAG_VALUE,
},
})
const awsS3PostRequest = await createFencyMemoryUpload(memory.id, {
fileName: file.name,
fileSize: file.size,
mimeType: file.type,
})
await uploadFileToS3(awsS3PostRequest, file)
const document = await documentRepository.insert({
userId,
fencyMemoryId: memory.id,
fileName: file.name,
contentStatus: 'EMPTY',
})
return NextResponse.json({
id: document.id,
fileName: document.fileName,
contentStatus: document.contentStatus,
contentParts: document.contentParts,
})
}
```
## Webhook: wait for SYNCHRONIZED [#webhook-wait-for-synchronized]
There is no `memory.synchronized` event. After Fency indexes the file, it sends [`memory.updated`](/api/events#memoryupdated). The `entity.contentStatus` field is `EMPTY`, `SYNCHRONIZING`, `SYNCHRONIZED`, or `SYNCHRONIZATION_ERROR`.
Verify the `x-fency-signature` header as described in [Webhooks](/api/webhooks). Read the raw body first. The helper lives at `api/fency-webhook/verifyFencySignature.ts`:
```ts
import { createHmac, timingSafeEqual } from 'node:crypto'
export function verifyFencySignature(payload: string, header: string | null) {
const secret = process.env.DOCUMENT_ANALYSIS_WEBHOOK_SECRET
if (!secret) {
throw new Error('DOCUMENT_ANALYSIS_WEBHOOK_SECRET is not defined.')
}
if (!header) {
throw new Error('Missing x-fency-signature header.')
}
const expected = Buffer.from(
`sha256=${createHmac('sha256', secret).update(payload).digest('hex')}`,
'utf8',
)
const received = Buffer.from(header, 'utf8')
if (
expected.length !== received.length ||
!timingSafeEqual(expected, received)
) {
throw new Error('Signature invalid')
}
}
```
The webhook route at `api/fency-webhook/route.ts` is public. A dashboard test send is a `user.test` event (`message: "Hello world!"`) with no `entity`; acknowledge it and return 200. On `memory.updated` it writes `contentStatus` for the matching memory. Unknown memory ids are ignored:
```ts
import { NextResponse } from 'next/server'
import { z } from 'zod'
import { contentStatusSchema } from '../../analyzedDocument'
import { documentRepository } from '../../db/documentRepository'
import { verifyFencySignature } from './verifyFencySignature'
const eventTypeSchema = z.object({
type: z.string(),
})
const userTestEventSchema = z.object({
type: z.literal('user.test'),
message: z.string(),
})
const memoryUpdatedEventSchema = z.object({
type: z.literal('memory.updated'),
entity: z.object({
id: z.string(),
contentStatus: contentStatusSchema,
contentParts: z.number().nullable().optional(),
}),
})
export async function POST(request: Request) {
const raw = await request.text()
verifyFencySignature(raw, request.headers.get('x-fency-signature'))
const body: unknown = JSON.parse(raw)
const { type } = eventTypeSchema.parse(body)
if (type === 'user.test') {
const event = userTestEventSchema.parse(body)
return NextResponse.json({ ok: true, message: event.message })
}
if (type !== 'memory.updated') {
return NextResponse.json({ ok: true })
}
const event = memoryUpdatedEventSchema.parse(body)
await documentRepository.updateContentStatus(
event.entity.id,
event.entity.contentStatus,
event.entity.contentParts ?? null,
)
return NextResponse.json({ ok: true })
}
```
The browser polls `api/get-document-status` until `contentStatus` is `SYNCHRONIZED`. Analysis is blocked until then.
## Server session routes [#server-session-routes]
The example defines three session routes. 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`:
```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 MemorySearch session at `api/create-memory-search-agent-task-session/route.ts`. Guard rails pin the task to the one uploaded memory:
```ts
import { NextResponse } from 'next/server'
import { z } from 'zod'
import { getAuthorizedUserId } from '../../../auth'
import { sessionClientTokenSchema } from '../../sessionClientTokenSchema'
import { DOCUMENT_MEMORY_TYPE_NAME } from '../../documentAnalysisConstants'
import { documentRepository } from '../../db/documentRepository'
import { memoryTypeRepository } from '../../db/memoryTypeRepository'
import { buildDocumentGuardRails } from './buildDocumentGuardRails'
const bodySchema = z.object({
documentId: z.string(),
})
export async function POST(request: Request) {
const userId = await getAuthorizedUserId()
if (!userId) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const { documentId } = bodySchema.parse(await request.json())
const document = await documentRepository.findById(documentId)
if (!document) {
return NextResponse.json({ error: 'Document not found' }, { status: 404 })
}
if (document.userId !== userId) {
return NextResponse.json(
{ error: 'Document does not belong to this user' },
{ status: 403 },
)
}
if (document.contentStatus !== 'SYNCHRONIZED') {
return NextResponse.json(
{ error: 'Document is not synchronized yet.' },
{ status: 409 },
)
}
const memoryType = await memoryTypeRepository.findByName(
DOCUMENT_MEMORY_TYPE_NAME,
)
if (!memoryType) {
throw new Error('AnalyzedDocument memory type is not set up.')
}
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: 'MEMORY_SEARCH',
metadata: { userId },
guardRails: buildDocumentGuardRails(
memoryType.fencyMemoryTypeId,
document.fencyMemoryId,
),
},
}),
})
if (!response.ok) {
throw new Error('Failed to create Fency session.')
}
return NextResponse.json(sessionClientTokenSchema.parse(await response.json()))
}
```
The guard rails helper at `api/create-memory-search-agent-task-session/buildDocumentGuardRails.ts`:
```ts
export function buildDocumentGuardRails(
memoryTypeId: string,
memoryId: string,
) {
return {
memoryTypes: [
{
memoryTypeId,
memoryIds: [memoryId],
},
],
}
}
```
Create the structured extraction session at `api/create-structured-agent-task-session/route.ts`:
```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({
createAgentTask: {
taskType: 'STRUCTURED_CHAT_COMPLETION',
metadata: { userId },
},
}),
})
if (!response.ok) {
throw new Error('Failed to create Fency session.')
}
return NextResponse.json(sessionClientTokenSchema.parse(await response.json()))
}
```
## Data points and JSON schema [#data-points-and-json-schema]
`MEMORY_SEARCH` returns ranked chunks, not typed JSON. The UI lets the user add rows of label, type, and description. Those rows become a Zod object, then a JSON Schema string for StructuredChatCompletion.
The data point types live in `dataPoint.ts`:
```ts
export const dataPointTypes = [
'string',
'stringList',
'boolean',
'number',
] as const
export type DataPointType = (typeof dataPointTypes)[number]
export type DataPoint = {
key: string
label: string
type: DataPointType
description: string
}
export const defaultDataPoints: DataPoint[] = [
{
key: 'title',
label: 'Title',
type: 'string',
description: 'The document title or heading',
},
{
key: 'parties',
label: 'Parties',
type: 'stringList',
description: 'Named people or organizations in the document',
},
{
key: 'isSigned',
label: 'Is signed',
type: 'boolean',
description: 'Whether the document appears signed',
},
{
key: 'amount',
label: 'Amount',
type: 'number',
description: 'A primary monetary amount if present',
},
]
```
Build the schema at runtime in `dataPointJsonSchema.ts`:
```ts
import { z } from 'zod'
import type { DataPoint } from './dataPoint'
function fieldForDataPoint(dataPoint: DataPoint) {
const description = dataPoint.description || dataPoint.label
switch (dataPoint.type) {
case 'string':
return z.string().nullish().describe(description)
case 'stringList':
return z.array(z.string()).nullish().describe(description)
case 'boolean':
return z.boolean().nullish().describe(description)
case 'number':
return z.number().nullish().describe(description)
}
}
export function dataPointZodSchema(dataPoints: DataPoint[]) {
const shape: Record> = {}
for (const dataPoint of dataPoints) {
shape[dataPoint.key] = fieldForDataPoint(dataPoint)
}
return z.object(shape)
}
export function dataPointJsonSchema(dataPoints: DataPoint[]) {
return JSON.stringify(z.toJSONSchema(dataPointZodSchema(dataPoints)))
}
```
## Client provider and analysis pipeline [#client-provider-and-analysis-pipeline]
Both session routes respond with a client token. Parse that response with a
Zod schema in `sessionClientTokenSchema.ts` so a bad shape throws:
```ts
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`:
This example mounts `FencyProvider` on its own page because every example in this repo is standalone. In your app, mount `FencyProvider` once in the root layout. Every `useAgentTasks` hook and every agent task under it shares the same streams. Do not add a provider per page, component, or task.
```tsx
'use client'
import { loadFency } from '@fencyai/js'
import { FencyProvider } from '@fencyai/react'
import { DocumentAnalyzer } from './components/DocumentAnalyzer'
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('/document-json-extraction/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 DocumentJsonExtractionPage() {
return (
)
}
```
`components/DocumentUploader.tsx` uses an Uppy drop field so you can drop or pick a PDF. Uppy posts `file` to `api/upload-document`.
`hooks/useDocumentAnalysis.ts` runs one MemorySearch per data point, then one StructuredChatCompletion. Each MemorySearch session is scoped to the selected document. The analysis list is one row per data point; click a row to open `AgentTaskProgress` for that search in `components/DataPointSearchProgressModal.tsx`. While StructuredChatCompletion runs, `AgentTaskProgress` also renders under the Analyze button.
The hook imports `ANALYSIS_MODEL` from `documentAnalysisConstants.ts`, which defines the model used for both MemorySearch and StructuredChatCompletion tasks:
```ts
export const DOCUMENT_MEMORY_TYPE_NAME = 'AnalyzedDocument'
export const DOCUMENT_TAG_KEY = 'documentAnalysisTag'
export const DOCUMENT_TAG_VALUE = 'document_analysis'
export const ANALYSIS_MODEL = 'anthropic/claude-sonnet-4.6'
```
Extract one data point with MemorySearch:
```ts
const searchResult = await createAgentTask(
{
type: 'MemorySearch',
query,
model: ANALYSIS_MODEL,
language: 'en',
chunkLimit: 5,
contextExpansion: { before: 1, after: 1 },
},
{
fetchCreateAgentTaskClientToken: () =>
fetchCreateMemorySearchClientToken(documentId),
onTaskRegistered: (task) => {
setDataPointSearches((prev) => [
...prev,
{
taskKey: task.taskKey,
label: dataPoint.label,
query,
},
])
},
},
)
```
Merge excerpts with StructuredChatCompletion:
```ts
const structResult = await createAgentTask(
{
type: 'StructuredChatCompletion',
messages: [
{
role: 'SYSTEM',
content:
'Merge facts from the labelled document search excerpts into the JSON schema. Use only information clearly supported by the excerpts. Leave a field null when it cannot be inferred.',
},
{
role: 'USER',
content: aggregated.join('\n\n---\n\n'),
},
],
jsonSchema,
model: ANALYSIS_MODEL,
temperature: 0.1,
},
{
fetchCreateAgentTaskClientToken: fetchCreateStructuredClientToken,
onTaskRegistered: (task) => {
setStructuringTaskKey(task.taskKey)
},
},
)
```
The completed MemorySearch event includes `items` with `matchingChunk` and surrounding `chunks` (page numbers and `MATCH` / `CONTEXT_BEFORE` / `CONTEXT_AFTER`). Those excerpts become the user message for structured extraction. Parse `event.response.jsonResponse` with the same Zod schema.
## Running locally [#running-locally]
Clone the repository and install dependencies:
```bash
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:
```bash
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
DOCUMENT_ANALYSIS_WEBHOOK_SECRET=whsec_...
```
Start Postgres and apply migrations:
```bash
docker compose up -d
npm run db:migrate
```
Initialize Clerk authentication (provisions a development app):
```bash
npx -y clerk@latest init --keyless -y
```
Start the development server and a public tunnel so Fency can deliver webhooks:
```bash
npm run dev
cloudflared tunnel --url http://localhost:3000
```
Create a webhook in the [Fency dashboard](https://app.fency.ai) pointing at `https:///document-json-extraction/api/fency-webhook`. Copy the generated secret into `DOCUMENT_ANALYSIS_WEBHOOK_SECRET`.
Open [http://localhost:3000/document-json-extraction](http://localhost:3000/document-json-extraction). Sign up from the header, upload a PDF, wait until the document is ready, then run analysis.
# JSON extraction (/docs/integration/json-extraction)
This example pastes free text and returns a typed person record that matches a Zod schema. The model returns a complete structured response with no intermediate text events.
- **Live demo**: [react.fency.ai/json-extraction](https://react.fency.ai/json-extraction)
- **GitHub folder**: [fency-react-examples/app/json-extraction](https://github.com/fencyai/fency-react-examples/tree/main/app/json-extraction)
## Prerequisites [#prerequisites]
You need a [publishable key](/docs/publishable-keys) for the React SDK and a [secret key](/docs/secret-keys) for your server routes. This example uses [sessions](/docs/sessions) to authenticate the client.
The example uses Clerk for user authentication. That is part of the app shell and not specific to Fency.
## Server session routes [#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`:
```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`:
```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({
createAgentTask: {
taskType: 'STRUCTURED_CHAT_COMPLETION',
metadata: { userId },
},
}),
})
if (!response.ok) {
throw new Error('Failed to create Fency session.')
}
return NextResponse.json(sessionClientTokenSchema.parse(await response.json()))
}
```
## Extraction schema [#extraction-schema]
Define the extraction schema using Zod in `extractionSchema.ts`:
```ts
import { z } from 'zod'
export const extractionSchema = z.object({
name: z.string().describe('Full name of the person'),
role: z.string().describe('Job title or role'),
company: z.string().describe('Company or organization'),
email: z
.string()
.describe('Email address if mentioned, otherwise an empty string'),
summary: z.string().describe('One-sentence summary of the person'),
})
export type Extraction = z.infer
export const extractionJsonSchema = JSON.stringify(
z.toJSONSchema(extractionSchema),
)
```
The schema descriptions guide the model on what to extract for each field.
## Client provider and hooks [#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:
```ts
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`:
This example mounts `FencyProvider` on its own page because every example in this repo is standalone. In your app, mount `FencyProvider` once in the root layout. Every `useAgentTasks` hook and every agent task under it shares the same streams. Do not add a provider per page, component, or task.
```tsx
'use client'
import { loadFency } from '@fencyai/js'
import { FencyProvider } from '@fencyai/react'
import { Extractor } from './components/Extractor'
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(
'/json-extraction/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 JsonExtractionPage() {
return (
)
}
```
Use `useAgentTasks` and `createAgentTask` to extract structured data in `hooks/useJsonExtraction.ts`:
```ts
'use client'
import { useAgentTasks } from '@fencyai/react'
import { useState } from 'react'
import {
extractionJsonSchema,
extractionSchema,
type Extraction,
} from '../extractionSchema'
import { sessionClientTokenSchema } from '../sessionClientTokenSchema'
async function fetchCreateAgentTaskClientToken() {
const res = await fetch(
'/json-extraction/api/create-agent-task-session',
{ method: 'POST' },
)
if (!res.ok) {
throw new Error('Failed to create agent task session')
}
const { clientToken } = sessionClientTokenSchema.parse(await res.json())
return { clientToken }
}
export function useJsonExtraction() {
const [isSubmitting, setIsSubmitting] = useState(false)
const [latestResult, setLatestResult] = useState(null)
const { latest, createAgentTask } = useAgentTasks({})
async function extract(text: string) {
setIsSubmitting(true)
setLatestResult(null)
try {
const response = await createAgentTask(
{
type: 'StructuredChatCompletion',
messages: [
{
role: 'SYSTEM',
content:
'Extract a single person record from the user text. Use empty strings for fields that are not mentioned.',
},
{ role: 'USER', content: text },
],
model: 'anthropic/claude-sonnet-4.6',
jsonSchema: extractionJsonSchema,
},
{ fetchCreateAgentTaskClientToken },
)
if (response.type !== 'success') {
throw new Error(response.error.message)
}
if (response.response.taskType !== 'StructuredChatCompletion') {
throw new Error('Unexpected StructuredChatCompletion outcome.')
}
setLatestResult(
extractionSchema.parse(
JSON.parse(response.response.response.jsonResponse),
),
)
} finally {
setIsSubmitting(false)
}
}
return {
latestTask: latest,
latestResult,
isSubmitting,
extract,
}
}
```
Render progress and the parsed record in `components/Extractor.tsx`:
```tsx
'use client'
import { Alert, Badge, Card, Text, Title } from '@mantine/core'
import { AgentTaskProgress } from '@fencyai/react'
import { useJsonExtraction } from '../hooks/useJsonExtraction'
import { ExtractionForm } from './ExtractionForm'
import { RecordCard } from './RecordCard'
import { SchemaPreview } from './SchemaPreview'
export function Extractor() {
const { latestTask, latestResult, isSubmitting, extract } =
useJsonExtraction()
return (
Basic
JSON extraction
Paste free text and get a typed record back.
Schema
Source text
{latestTask?.error ? (
{latestTask.error.message}
) : latestTask ? (
) : null}
{latestResult ? (
) : null}
)
}
```
The `AgentTaskProgress` component displays progress while the extraction task runs. The result is parsed with the Zod schema to ensure type safety. `components/ExtractionForm.tsx` owns the textarea and calls `extract`.
## Running locally [#running-locally]
Clone the repository and install dependencies:
```bash
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:
```bash
cp .env.example .env.local
```
Set the keys in `.env.local`:
```
FENCY_SECRET_KEY=sk_...
NEXT_PUBLIC_FENCY_PUBLISHABLE_KEY=pk_...
```
Initialize Clerk authentication (provisions a development app):
```bash
npx -y clerk@latest init --keyless -y
```
Start the development server:
```bash
npm run dev
```
Open [http://localhost:3000/json-extraction](http://localhost:3000/json-extraction). Sign up from the header, then paste free text to extract structured JSON matching the schema.
# JSON response (/docs/integration/json-response)
This example sends one prompt and returns one JSON object with a string, a boolean, and a number. The model returns a complete structured response with no intermediate text events.
- **Live demo**: [react.fency.ai/json-response](https://react.fency.ai/json-response)
- **GitHub folder**: [fency-react-examples/app/json-response](https://github.com/fencyai/fency-react-examples/tree/main/app/json-response)
## Prerequisites [#prerequisites]
You need a [publishable key](/docs/publishable-keys) for the React SDK and a [secret key](/docs/secret-keys) for your server routes. This example uses [sessions](/docs/sessions) to authenticate the client.
The example uses Clerk for user authentication. That is part of the app shell and not specific to Fency.
## Server session routes [#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`:
```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`:
```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({
createAgentTask: {
taskType: 'STRUCTURED_CHAT_COMPLETION',
metadata: { userId },
},
}),
})
if (!response.ok) {
throw new Error('Failed to create Fency session.')
}
return NextResponse.json(sessionClientTokenSchema.parse(await response.json()))
}
```
## Response schema [#response-schema]
Define the response schema using Zod in `responseSchema.ts`. It includes a
string, a boolean, and a number:
```ts
import { z } from 'zod'
export const responseSchema = z.object({
name: z.string().describe('City name'),
coastal: z.boolean().describe('Whether the city sits on a coast'),
population: z.number().describe('Approximate population'),
})
export type JsonResponse = z.infer
export const responseJsonSchema = JSON.stringify(z.toJSONSchema(responseSchema))
```
The schema descriptions guide the model on what to return for each field.
## Client provider and hooks [#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:
```ts
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`:
This example mounts `FencyProvider` on its own page because every example in this repo is standalone. In your app, mount `FencyProvider` once in the root layout. Every `useAgentTasks` hook and every agent task under it shares the same streams. Do not add a provider per page, component, or task.
```tsx
'use client'
import { loadFency } from '@fencyai/js'
import { FencyProvider } from '@fencyai/react'
import { JsonViewer } from './components/JsonViewer'
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('/json-response/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 JsonResponsePage() {
return (
)
}
```
Use `useAgentTasks` and `createAgentTask` to get the JSON object in `hooks/useJsonResponse.ts`:
```ts
'use client'
import { useAgentTasks } from '@fencyai/react'
import { useState } from 'react'
import {
responseJsonSchema,
responseSchema,
type JsonResponse,
} from '../responseSchema'
import { sessionClientTokenSchema } from '../sessionClientTokenSchema'
async function fetchCreateAgentTaskClientToken() {
const res = await fetch('/json-response/api/create-agent-task-session', {
method: 'POST',
})
if (!res.ok) {
throw new Error('Failed to create agent task session')
}
const { clientToken } = sessionClientTokenSchema.parse(await res.json())
return { clientToken }
}
export function useJsonResponse() {
const [isSubmitting, setIsSubmitting] = useState(false)
const [latestResult, setLatestResult] = useState(null)
const { latest, createAgentTask } = useAgentTasks({})
async function getJson(prompt: string) {
setIsSubmitting(true)
setLatestResult(null)
try {
const response = await createAgentTask(
{
type: 'StructuredChatCompletion',
messages: [{ role: 'USER', content: prompt }],
model: 'anthropic/claude-sonnet-4.6',
jsonSchema: responseJsonSchema,
},
{ fetchCreateAgentTaskClientToken },
)
if (response.type !== 'success') {
throw new Error(response.error.message)
}
if (response.response.taskType !== 'StructuredChatCompletion') {
throw new Error('Unexpected StructuredChatCompletion outcome.')
}
setLatestResult(
responseSchema.parse(
JSON.parse(response.response.response.jsonResponse),
),
)
} finally {
setIsSubmitting(false)
}
}
return {
latestTask: latest,
latestResult,
isSubmitting,
getJson,
}
}
```
Render progress and the parsed object in `components/JsonViewer.tsx`:
```tsx
'use client'
import { Alert, Badge, Text, Title } from '@mantine/core'
import { AgentTaskProgress } from '@fencyai/react'
import { useJsonResponse } from '../hooks/useJsonResponse'
import { JsonResult } from './JsonResult'
import { PromptForm } from './PromptForm'
export function JsonViewer() {
const { latestTask, latestResult, isSubmitting, getJson } = useJsonResponse()
return (
Basic
JSON response
Name a city. The reply is a JSON object with a string, a boolean, and
a number.
{latestTask?.error ? (
{latestTask.error.message}
) : latestTask ? (
) : null}
{latestResult ?
: null}
)
}
```
The `AgentTaskProgress` component displays progress while the task runs. The result is parsed with the Zod schema to ensure type safety. `components/PromptForm.tsx` owns the textarea and calls `getJson`. `components/JsonResult.tsx` shows the parsed object as formatted JSON.
## Running locally [#running-locally]
Clone the repository and install dependencies:
```bash
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:
```bash
cp .env.example .env.local
```
Set the keys in `.env.local`:
```
FENCY_SECRET_KEY=sk_...
NEXT_PUBLIC_FENCY_PUBLISHABLE_KEY=pk_...
```
Initialize Clerk authentication (provisions a development app):
```bash
npx -y clerk@latest init --keyless -y
```
Start the development server:
```bash
npm run dev
```
Open [http://localhost:3000/json-response](http://localhost:3000/json-response). Sign up from the header, then send a prompt to get a JSON object with a string, a boolean, and a number.
# Streaming response (/docs/integration/streaming-response)
This example sends one prompt and streams the reply as text. There is no chat thread and no multi-turn history.
- **Live demo**: [react.fency.ai/streaming-response](https://react.fency.ai/streaming-response)
- **GitHub folder**: [fency-react-examples/app/streaming-response](https://github.com/fencyai/fency-react-examples/tree/main/app/streaming-response)
## Prerequisites [#prerequisites]
You need a [publishable key](/docs/publishable-keys) for the React SDK and a [secret key](/docs/secret-keys) for your server routes. This example uses [sessions](/docs/sessions) to authenticate the client.
The example uses Clerk for user authentication. That is part of the app shell and not specific to Fency.
## Server session routes [#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`:
```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`:
```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({
createAgentTask: {
taskType: 'STREAMING_CHAT_COMPLETION',
metadata: { userId },
},
}),
})
if (!response.ok) {
throw new Error('Failed to create Fency session.')
}
return NextResponse.json(sessionClientTokenSchema.parse(await response.json()))
}
```
## Client provider and hooks [#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:
```ts
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`:
This example mounts `FencyProvider` on its own page because every example in this repo is standalone. In your app, mount `FencyProvider` once in the root layout. Every `useAgentTasks` hook and every agent task under it shares the same streams. Do not add a provider per page, component, or task.
```tsx
'use client'
import { loadFency } from '@fencyai/js'
import { FencyProvider } from '@fencyai/react'
import { Streamer } from './components/Streamer'
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('/streaming-response/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 StreamingResponsePage() {
return (
)
}
```
Use `useAgentTasks` and `createAgentTask` to send one user message in `hooks/useStreamingResponse.ts`:
```ts
'use client'
import { useAgentTasks } from '@fencyai/react'
import { useState } from 'react'
import { sessionClientTokenSchema } from '../sessionClientTokenSchema'
async function fetchCreateAgentTaskClientToken() {
const res = await fetch('/streaming-response/api/create-agent-task-session', {
method: 'POST',
})
if (!res.ok) {
throw new Error('Failed to create agent task session')
}
const { clientToken } = sessionClientTokenSchema.parse(await res.json())
return { clientToken }
}
export function useStreamingResponse() {
const [isSubmitting, setIsSubmitting] = useState(false)
const { latest, createAgentTask } = useAgentTasks({})
async function generate(prompt: string) {
setIsSubmitting(true)
try {
const response = await createAgentTask(
{
type: 'StreamingChatCompletion',
messages: [{ role: 'USER', content: prompt }],
model: 'anthropic/claude-sonnet-4.6',
},
{ fetchCreateAgentTaskClientToken },
)
if (response.type !== 'success') {
throw new Error(response.error.message)
}
if (response.response.taskType !== 'StreamingChatCompletion') {
throw new Error('Unexpected StreamingChatCompletion outcome.')
}
const assistant = response.response.response.messages.at(-1)
if (assistant?.role !== 'ASSISTANT') {
throw new Error('StreamingChatCompletion did not return an assistant message.')
}
} finally {
setIsSubmitting(false)
}
}
return {
latestTask: latest,
isSubmitting,
generate,
}
}
```
Render the prompt and the streaming reply in `components/Streamer.tsx`:
```tsx
'use client'
import { Alert, Badge, Text, Title } from '@mantine/core'
import { AgentTaskProgress } from '@fencyai/react'
import { useStreamingResponse } from '../hooks/useStreamingResponse'
import { PromptForm } from './PromptForm'
export function Streamer() {
const { latestTask, isSubmitting, generate } = useStreamingResponse()
return (
Basic
Streaming response
One prompt. The reply streams in as it is generated.
{latestTask?.error ? (
{latestTask.error.message}
) : latestTask ? (
) : null}
)
}
```
The `AgentTaskProgress` component displays the streaming text as it arrives.
`components/PromptForm.tsx` owns the textarea and calls `generate`.
## Running locally [#running-locally]
Clone the repository and install dependencies:
```bash
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:
```bash
cp .env.example .env.local
```
Set the keys in `.env.local`:
```
FENCY_SECRET_KEY=sk_...
NEXT_PUBLIC_FENCY_PUBLISHABLE_KEY=pk_...
```
Initialize Clerk authentication (provisions a development app):
```bash
npx -y clerk@latest init --keyless -y
```
Start the development server:
```bash
npm run dev
```
Open [http://localhost:3000/streaming-response](http://localhost:3000/streaming-response). Sign up from the header, then generate a reply to see tokens stream in real time.
# Memories (/docs/memories)
Memories are how your backend mirrors application data into Fency. Create and update them from your server with a [secret key](/docs/secret-keys). Agent tasks can then use those memories as context - for search, chat, or exploration.
Each memory belongs to a [memory type](/api/memory-types) that defines its metadata schema. You decide which memories a session can see when you create that session with [guardrails](/docs/sessions/guardrails).
## Source types [#source-types]
A memory is created with one of four source types. `TEXT`, `URL`, and `FILE` are for semantic search: Fency chunks the content and indexes it in a vector store so an agent can search it. `METADATA` is for typed fields only, with no chunking or vector index.
### TEXT [#text]
Use `TEXT` when you already have the text. Send it in the create request. Fency chunks and indexes it so an agent can perform semantic search.
### URL [#url]
Use `URL` when the content lives at a downloadable file URL. The `url` must point to a file Fency can download. Fency downloads it, then chunks and indexes it the same way as text. The link can for example be a presigned URL from Amazon S3 or a similar object store.
### FILE [#file]
Use `FILE` when you have the file on your server instead of a downloadable URL. Create an empty memory and upload the bytes. Fency then chunks and indexes it the same way as text. See [Uploading files for memories](/api/memory-uploads/uploading-files).
### METADATA [#metadata]
Use `METADATA` when semantic search does not matter. There is no text body to chunk. The agent only gets typed fields such as `STRING`, `STRING_LIST`, `INTEGER`, `DECIMAL`, and `BOOLEAN`. This is the easiest source type to create: memories are ready immediately, and you can upsert large amounts at once with [Memory bulk jobs](/api/memory-bulk-jobs).
## Memory types [#memory-types]
[Memory types](/api/memory-types) are schemas: a name, optional description, and metadata fields (`STRING`, `STRING_LIST`, `BOOLEAN`, `INTEGER`, `DECIMAL`). Create a memory type first, then create memories under its `memoryTypeId`.
In practice, create one memory type per database table you mirror (`cars`, `service_records`). Give the agent access through metadata on those memories, not by stuffing every entity kind into `guardRails.memoryTypes`. A session can list several types; those entries are ORed. Duplicate memory type IDs are rejected. See [Guardrails](/docs/sessions/guardrails) for match rules and for combining lot, share, and seller access.
A memory type is either `SEMANTIC` or `METADATA`. That kind decides which source types you can create under it:
| Memory type | Memory source types | What the agent can do |
| --- | --- | --- |
| `SEMANTIC` | `TEXT`, `URL`, `FILE` | Semantic search over chunked, indexed content |
| `METADATA` | `METADATA` | Read typed fields only (`STRING`, `STRING_LIST`, `INTEGER`, `DECIMAL`, `BOOLEAN`) |
`METADATA` types require `identityKeyName` and `updatedAtKeyName` on create (the upsert identity and updated-at keys). Do not send those on a `SEMANTIC` type.
Create a `SEMANTIC` type for `TEXT`, `URL`, and `FILE` memories:
```js
const semanticType = await fetch('https://api.fency.ai/v1/memory-types', {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.FENCY_SECRET_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
name: 'Contracts',
description: 'Customer contracts and related files.',
type: 'SEMANTIC',
}),
})
```
Create a `METADATA` type for typed-field memories. `identityKeyName` and `updatedAtKeyName` are required:
```js
const metadataType = await fetch('https://api.fency.ai/v1/memory-types', {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.FENCY_SECRET_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
name: 'Products',
description: 'Catalog products with typed fields.',
type: 'METADATA',
identityKeyName: 'sku',
updatedAtKeyName: 'last_modified',
}),
})
```
For the full request fields, see [Create memory type](/api/memory-types/createMemoryType).
## Creating a memory [#creating-a-memory]
Call `POST /v1/memories` from your server. For request fields per source type, see [Memories](/api/memories).
This example creates a `URL` memory under the `Contracts` type. Fency downloads the file at `url`, then chunks and indexes it.
```js
const response = await fetch('https://api.fency.ai/v1/memories', {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.FENCY_SECRET_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
memoryTypeId: 'mty_...',
sourceType: 'URL',
title: 'Acme MSA',
url: 'https://example.com/contracts/acme-msa.pdf',
metadata: {
organization_id: 'org_1234567890',
},
}),
})
```
Fency processes the download asynchronously. A `memory.updated` webhook tells you when the memory is ready. See [Events](/api/events).
## Updating memories [#updating-memories]
Call `PATCH /v1/memories/{memoryId}` to change title, content, or metadata. You do not need to delete and recreate a memory when a car moves lots or is shared with another seller. Update the metadata (for example a `grants` `STRING_LIST`) and the next session's [guardrails](/docs/sessions/guardrails) will see the new values.
To update many memories at once, use [Memory bulk jobs](/api/memory-bulk-jobs). For request fields, see [Update memory](/api/memories/updateMemory).
## Using memories [#using-memories]
Give an agent access to memories for a task by setting `guardRails` when you [create the session](/docs/sessions). The webapp then starts an [agent task](/docs/agent-tasks) with the returned `clientToken`. See [Guardrails](/docs/sessions/guardrails) for the `guardRails.memoryTypes` shape.
# Publishable keys (/docs/publishable-keys)
Publishable keys are designed for client-side use in your React webapp. They are safe to expose in browser code. They are used in combination with [sessions](/docs/sessions) created with a [secret key](/docs/secret-keys) in your backend.
A publishable key always starts with `pk_`:
```
pk_1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef
```
## Creating a publishable key [#creating-a-publishable-key]
To create a publishable key, navigate to your [Fency.ai dashboard](https://app.fency.ai/dashboard) and go to the API Keys section. Click "Create new key" and select "Publishable key" as the key type.
## Allowed origins [#allowed-origins]
Allowed origins (CORS) restrict which domains can use your publishable key, providing an additional layer of security. When creating or editing a publishable key, you can specify which domains are allowed to make requests:
- **Development:** `http://localhost:3000`, `http://localhost:5173`
- **Production:** `https://yourdomain.com`
## Best practices [#best-practices]
- Use separate publishable keys for local development and production.
- Only add origins that actually need access.
- Regularly review and update your allowed origins list, especially for production.
# Secret keys (/docs/secret-keys)
Secret keys authenticate server-to-server requests from your backend to the Fency.ai API. They must never be exposed in client-side code or public repositories. Use secret keys when creating sessions, managing memories, or making any API calls from your server.
A secret key always starts with `sk_` and is longer than a publishable key:
```
sk_1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef
```
## Creating a secret key [#creating-a-secret-key]
To create a secret key, navigate to your [Fency.ai dashboard](https://app.fency.ai/dashboard) and go to the API Keys section. Click "Create new key" and select "Secret key" as the key type.
## Storing your secret key [#storing-your-secret-key]
Store your secret key as a server-side environment variable. For local development, add it to your `.env` or `.env.local` file:
```bash
FENCY_SECRET_KEY=sk_...
```
In production, prefer injecting it via your hosting platform's environment variable settings or a dedicated secrets manager (such as AWS Secrets Manager, Google Cloud Secret Manager, or HashiCorp Vault) rather than committing it to a file.
Never expose it to the browser — do not include it in client-side bundles, public environment variables, or any other mechanism that makes it accessible from the browser.
For a full overview of operations, see the [API Reference](/api).
# Allowed actions (/docs/sessions/allowed-actions)
`allowedActions` control which actions and components an agent may declare in the browser. Your server sets them when creating a `createAgentTask` [session](/docs/sessions). The React SDK only receives a `clientToken` and cannot widen the list.
How `allowedActions` apply per task type:
| Task type | `allowedActions` |
| --- | --- |
| `EXPLORE_MEMORIES` | Optional. Allowlist of component names, such as `renderPieChart`. Absent means unrestricted. |
| `EXPLORE_PRODUCT` | Optional. Allowlist of action and component names, such as `setTheme` and `renderPieChart`. Absent means unrestricted. |
| `MEMORY_CHAT` | Not supported |
| `MEMORY_SEARCH` | Not supported |
| `STREAMING_CHAT_COMPLETION` | Not supported |
| `STRUCTURED_CHAT_COMPLETION` | Not supported |
## How they work [#how-they-work]
The names you list are the only ones the webapp may declare when it starts the task. If the client declares a name that is not on the list, task creation fails.
- Explore Memories uses `allowedActions` for components only (`renderPieChart`, `renderTimeseriesChart`).
- Explore Product uses `allowedActions` for both actions (`setTheme`, `applyFilter`) and components.
Leave the field off when any declared name should be allowed.
## Examples [#examples]
**`EXPLORE_MEMORIES`**
```js
{
createAgentTask: {
taskType: 'EXPLORE_MEMORIES',
allowedActions: ['renderPieChart', 'renderTimeseriesChart'],
},
}
```
**`EXPLORE_PRODUCT`**
```js
{
createAgentTask: {
taskType: 'EXPLORE_PRODUCT',
allowedActions: ['setTheme', 'applyFilter'],
},
}
```
See [Sessions](/docs/sessions) for full request bodies per task type.
# Background (/docs/sessions/background)
`background` is optional guidance for how the agent should explore memories. Your server sets it when creating a `createAgentTask` [session](/docs/sessions). The React SDK only receives a `clientToken` and cannot change it.
How `background` applies per task type:
| Task type | `background` |
| --- | --- |
| `EXPLORE_MEMORIES` | Optional. A string that steers the agent, such as a role or domain. |
| `EXPLORE_PRODUCT` | Not supported |
| `MEMORY_CHAT` | Not supported |
| `MEMORY_SEARCH` | Not supported |
| `STREAMING_CHAT_COMPLETION` | Not supported |
| `STRUCTURED_CHAT_COMPLETION` | Not supported |
## How they work [#how-they-work]
Use `background` to tell the agent how to approach Explore Memories. Leave it off when the default behavior is enough. Sending `background` on any other task type is rejected.
## Examples [#examples]
**`EXPLORE_MEMORIES`**
```js
{
createAgentTask: {
taskType: 'EXPLORE_MEMORIES',
background: 'You help users find relevant contracts from document content and metadata.',
},
}
```
See [Sessions](/docs/sessions) for full request bodies per task type.
# Conversation (/docs/sessions/conversation)
`conversationId` attaches the task to an existing conversation so later tasks can share that thread. Your server sets it when creating a `createAgentTask` [session](/docs/sessions). The React SDK only receives a `clientToken` and cannot change it.
How `conversationId` applies per task type:
| Task type | `conversationId` |
| --- | --- |
| `EXPLORE_MEMORIES` | Optional |
| `EXPLORE_PRODUCT` | Optional |
| `MEMORY_CHAT` | Not supported |
| `MEMORY_SEARCH` | Not supported |
| `STREAMING_CHAT_COMPLETION` | Not supported |
| `STRUCTURED_CHAT_COMPLETION` | Not supported |
## How they work [#how-they-work]
- Create the conversation first with [Create conversation](/api/conversations/createConversation). Conversations are never created as a side effect of a session.
- Pass the returned `cnv_...` id as `conversationId`. An unknown id fails.
- Title stays empty until the first attached task that has a query sets it.
## Examples [#examples]
**`EXPLORE_MEMORIES`**
```js
{
createAgentTask: {
taskType: 'EXPLORE_MEMORIES',
conversationId: 'cnv_...',
},
}
```
See [Conversations](/api/conversations) for the conversation object, [Data exploration](/docs/integration/data-exploration) for a create-then-attach server route, and [Sessions](/docs/sessions) for full request bodies per task type.
# Examples (/docs/sessions/examples)
`examples` is an optional list of query and approach pairs that show the agent how to handle typical questions. Your server sets them when creating a `createAgentTask` [session](/docs/sessions). The React SDK only receives a `clientToken` and cannot change them.
How `examples` apply per task type:
| Task type | `examples` |
| --- | --- |
| `EXPLORE_MEMORIES` | Optional. A list of query and approach pairs. |
| `EXPLORE_PRODUCT` | Not supported |
| `MEMORY_CHAT` | Not supported |
| `MEMORY_SEARCH` | Not supported |
| `STREAMING_CHAT_COMPLETION` | Not supported |
| `STRUCTURED_CHAT_COMPLETION` | Not supported |
## How they work [#how-they-work]
Each item needs:
- `query`: an example user question
- `approach`: how the agent should handle that question
Leave `examples` off when you do not want to steer the agent with sample questions. Sending `examples` on any other task type is rejected.
## Examples [#examples]
**`EXPLORE_MEMORIES`**
```js
{
createAgentTask: {
taskType: 'EXPLORE_MEMORIES',
examples: [
{
query: 'Which parents lack a matching child?',
approach: 'Use keywordSearch parentForEach and filter empty search_matching_memory_ids.',
},
],
},
}
```
See [Sessions](/docs/sessions) for full request bodies per task type.
# Guardrails (/docs/sessions/guardrails)
`guardRails` control which memories an agent can access. Your server sets them when creating a `createAgentTask` [session](/docs/sessions). The React SDK only receives a `clientToken` and cannot expand the rails.
How `guardRails` apply per task type:
| Task type | `guardRails` |
| --- | --- |
| `EXPLORE_MEMORIES` | Required. Both `METADATA` and `SEMANTIC` memory types are supported. |
| `MEMORY_CHAT` | Required. Only `SEMANTIC` memory types are supported. |
| `MEMORY_SEARCH` | Required. Only `SEMANTIC` memory types are supported. |
| `EXPLORE_PRODUCT` | Optional. Set them when the agent should perform semantic search on memories. The memory types used must be of type `SEMANTIC`. |
| `STREAMING_CHAT_COMPLETION` | Not supported |
| `STRUCTURED_CHAT_COMPLETION` | Not supported |
## How they work [#how-they-work]
Set `guardRails.memoryTypes` to a list of memory types the agent may see. Entries are ORed: the agent can read any memory that satisfies at least one entry.
Each entry uses exactly one `memoryTypeId`, and exactly one scope. Combining `memoryIds`, `match`, and `includeAll` on the same entry is rejected (`400`). Two entries with the same `memoryTypeId` are also rejected (`400 Duplicate memory types found in guard rails`).
| Scope | What the agent can access |
| --- | --- |
| `match` | Memories of that type whose metadata matches the filter |
| `memoryIds` | Only the listed memories of that type (max 100 Fency `mem_...` IDs) |
| `includeAll` | Every memory of that type |
Use several entries when the agent should see more than one type at once, for example cars and the service records for those cars. Each type gets its own rail and its own scope. See [Memories](/docs/memories) for why you typically create one memory type per database table.
## Match rules [#match-rules]
`match` filters on metadata you stamped when you [created or updated the memory](/docs/memories).
- Keys in one `match` object are ANDed. A memory must satisfy every key.
- Array values are ORed. A memory matches if its stored value is any one of the listed strings.
- A memory that is missing the key never matches.
- `STRING` keys compare equality (or membership when the rail value is an array).
- `STRING_LIST` keys compare overlap: the memory matches if at least one stored value appears in the rail's list.
```js
{
memoryTypeId: 'mty_cars',
match: {
brand: 'volvo',
lot_id: ['oslo', 'bergen'],
},
}
```
This admits memories of that type that have `brand` equal to `volvo` **and** `lot_id` equal to `oslo` or `bergen`.
Because keys are ANDed, you cannot express "on lot oslo **or** this specific car" with two separate keys such as `lot_id` plus `memoryIds`. Use a single `STRING_LIST` field and OR the values. That pattern is below.
## Combining lot, share, and seller access [#combining-lot-share-and-seller-access]
A common access model is the union of:
- every car on the user's lots
- specific cars shared with the user
- cars the user listed
You cannot combine `match` and `memoryIds` on the same rail, and you cannot add a second rail for the same `memoryTypeId`. `memoryIds` is also limited to 100 Fency IDs, so it does not scale to "every car shared with this user."
Stamp your own IDs on each memory as a `STRING_LIST` field (for example `grants`) and query that field with `match`. Prefixes such as `lot:`, `car:`, and `seller:` are a convention you choose, not fields Fency defines.
**At index time**, write every token that should grant access to that memory:
```js
{
memoryTypeId: 'mty_cars',
sourceType: 'TEXT',
title: '2022 Volvo XC40',
text: '...',
metadata: {
grants: ['lot:oslo', 'car:car_1', 'seller:usr_ada'],
},
}
```
A second car on another lot:
```js
{
memoryTypeId: 'mty_cars',
sourceType: 'TEXT',
title: '2019 Tesla Model 3',
text: '...',
metadata: {
grants: ['lot:bergen', 'car:car_2', 'seller:usr_kai'],
},
}
```
**At session create**, OR the tokens the current user is allowed to see:
```js
{
createAgentTask: {
taskType: 'MEMORY_SEARCH',
guardRails: {
memoryTypes: [
{
memoryTypeId: 'mty_cars',
match: {
grants: [
'lot:oslo',
'car:car_2',
'seller:usr_ada',
],
},
},
],
},
},
}
```
That query returns both memories: the first overlaps on `lot:oslo` (and `seller:usr_ada`), the second overlaps on `car:car_2`. A car that only has `lot:trondheim` does not match.
If you need AND inside one grant (for example "this seller's own listings on this lot only"), encode that as one token such as `seller:usr_ada:lot:oslo` and stamp it on those memories.
When a car moves lots or is shared with another seller, [update the memory's metadata](/docs/memories). You do not need to delete and recreate it.
Keep classification markers (sensitivity, PII) on their own `STRING` key. Overlap on a list is the right rule for ownership and sharing labels, not for "must not include this tag."
## Metadata overrides [#metadata-overrides]
Each memory type entry can include a `metadata` array that overrides the stored meta-key schema for that session only:
- `visible`: whether the agent can see that key.
- `description`: the description the agent gets for that key, overriding the stored one.
Each entry needs a `key` plus at least one of `visible` or `description`.
## chunkLimit [#chunklimit]
`chunkLimit` is set on the [agent task](/docs/agent-tasks) (`MemoryChat` and `MemorySearch`), not on the session. It is applied after guard rails filter the corpus. You do not need to raise `chunkLimit` to compensate for memories the rails would discard.
## Examples [#examples]
**`match`**
```js
{
createAgentTask: {
taskType: 'EXPLORE_MEMORIES',
guardRails: {
memoryTypes: [
{
memoryTypeId: 'mty_...',
match: { organization_id: 'org_...' },
metadata: [
{
key: 'renewal_date',
visible: true,
description: 'ISO-8601 date the contract auto-renews',
},
{
key: 'internal_notes',
visible: false,
},
],
},
],
},
},
}
```
**`memoryIds`**
```js
{
createAgentTask: {
taskType: 'MEMORY_CHAT',
guardRails: {
memoryTypes: [
{
memoryTypeId: 'mty_...',
memoryIds: ['mem_...', 'mem_...'],
metadata: [
{
key: 'renewal_date',
visible: true,
description: 'ISO-8601 date the contract auto-renews',
},
],
},
],
},
},
}
```
**`includeAll`**
```js
{
createAgentTask: {
taskType: 'MEMORY_SEARCH',
guardRails: {
memoryTypes: [
{
memoryTypeId: 'mty_...',
includeAll: true,
},
],
},
},
}
```
**Two memory types (OR)**
```js
{
createAgentTask: {
taskType: 'MEMORY_CHAT',
guardRails: {
memoryTypes: [
{
memoryTypeId: 'mty_cars',
match: { vin: 'vin_1' },
},
{
memoryTypeId: 'mty_service_records',
match: {
grants: ['car:vin_1'],
},
},
],
},
},
}
```
See [Sessions](/docs/sessions) for full request bodies per task type, and [Data exploration](/docs/integration/data-exploration) for a complete server route.
# Sessions (/docs/sessions)
A session is how your backend controls access to the Fency APIs. Your server creates the session with a [secret key](/docs/secret-keys) and passes a short-lived `clientToken` to the webapp. The React SDK uses that token to call Fency. It never sees the secret key and cannot widen what the session allows.
Here are the sessions you can create today:
| Field | Purpose |
| --- | --- |
| `createStream` | Creates a `clientToken` allowing the webapp to create a new stream it can listen to. This is a bootstrapping session and is always required. |
| `createAgentTask` | Creates a `clientToken` allowing the webapp to start an agent task such as chat, memory search, explore memories, or explore product. Required whenever the webapp starts a task. |
| `createAgentTaskFileDownload` | Creates a `clientToken` allowing the webapp to download a file produced by an agent task. Create this session when the user downloads a file. |
| `getAgentTaskResponse` | Creates a `clientToken` allowing the webapp to fetch the response of a completed agent task. Create this session when the webapp needs the finished result. |
| `submitManualActionResult` | Creates a `clientToken` allowing the webapp to submit the result of a manual action. Create this session when a task is waiting for a human or browser action result. |
## How it works [#how-it-works]
1. **Client SDK initiates the request:** When the React SDK needs to make API requests (e.g. for creating a new stream), it calls the `fetchCreateStreamClientToken` function you pass to `FencyProvider`, which requests a client token from your server endpoint (e.g. `/api/stream-client-token`).
2. **Server creates a session:** Your backend receives the request and calls the Fency API with your secret key. The API returns a session object that includes a `clientToken`, which your endpoint returns to the frontend.
3. **SDK uses the token:** The React SDK receives the `clientToken` and uses it to authenticate with the Fency.ai API.
Your server creates the session the same way every time. Only the request body changes.
```js
// server route handler (e.g. POST /api/agent-task-session)
const secretKey = process.env.FENCY_SECRET_KEY
if (!secretKey) {
throw new Error('FENCY_SECRET_KEY is not defined.')
}
export async function POST() {
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: 'STREAMING_CHAT_COMPLETION',
},
}),
})
const data = await response.json()
return new Response(JSON.stringify(data), {
status: response.status,
headers: { 'Content-Type': 'application/json' },
})
}
```
## Examples [#examples]
Swap the body for the session you need. Each `guardRails.memoryTypes` entry uses exactly one scope (`memoryIds`, `match`, or `includeAll`) and a unique `memoryTypeId`. See [Guardrails](/docs/sessions/guardrails) for match rules and for combining lot, share, and seller access on one type.
**`createStream`**
```js
{
createStream: {},
}
```
**`createAgentTask` (`STREAMING_CHAT_COMPLETION`)**
```js
{
createAgentTask: {
taskType: 'STREAMING_CHAT_COMPLETION',
metadata: {
userId: 'usr_...',
},
},
}
```
**`createAgentTask` (`STRUCTURED_CHAT_COMPLETION`)**
```js
{
createAgentTask: {
taskType: 'STRUCTURED_CHAT_COMPLETION',
metadata: {
userId: 'usr_...',
},
},
}
```
**`createAgentTask` (`MEMORY_CHAT`)**
```js
{
createAgentTask: {
taskType: 'MEMORY_CHAT',
metadata: {
userId: 'usr_...',
},
guardRails: {
memoryTypes: [
{
memoryTypeId: 'mty_contracts',
match: {
organization_id: 'org_...',
tags: ['renewal', 'enterprise'],
},
metadata: [
{
key: 'renewal_date',
visible: true,
description: 'ISO-8601 date the contract auto-renews',
},
{
key: 'internal_notes',
visible: false,
},
],
},
{
memoryTypeId: 'mty_invoices',
memoryIds: ['mem_...', 'mem_...'],
metadata: [
{
key: 'renewal_date',
visible: true,
description: 'ISO-8601 date the contract auto-renews',
},
],
},
{
memoryTypeId: 'mty_notes',
includeAll: true,
},
],
},
},
}
```
**`createAgentTask` (`MEMORY_SEARCH`)**
```js
{
createAgentTask: {
taskType: 'MEMORY_SEARCH',
metadata: {
userId: 'usr_...',
},
guardRails: {
memoryTypes: [
{
memoryTypeId: 'mty_contracts',
match: {
organization_id: 'org_...',
tags: ['renewal', 'enterprise'],
},
metadata: [
{
key: 'renewal_date',
visible: true,
description: 'ISO-8601 date the contract auto-renews',
},
{
key: 'internal_notes',
visible: false,
},
],
},
{
memoryTypeId: 'mty_invoices',
memoryIds: ['mem_...', 'mem_...'],
metadata: [
{
key: 'renewal_date',
visible: true,
description: 'ISO-8601 date the contract auto-renews',
},
],
},
{
memoryTypeId: 'mty_notes',
includeAll: true,
},
],
},
},
}
```
**`createAgentTask` (`EXPLORE_MEMORIES`)**
```js
{
createAgentTask: {
taskType: 'EXPLORE_MEMORIES',
conversationId: 'cnv_...',
metadata: {
userId: 'usr_...',
},
background: 'You help users find relevant contracts from document content and metadata.',
examples: [
{
query: 'Which parents lack a matching child?',
approach: 'Use keywordSearch parentForEach and filter empty search_matching_memory_ids.',
},
],
allowedActions: ['renderPieChart', 'renderTimeseriesChart'],
guardRails: {
memoryTypes: [
{
memoryTypeId: 'mty_contracts',
match: {
organization_id: 'org_...',
tags: ['renewal', 'enterprise'],
},
metadata: [
{
key: 'renewal_date',
visible: true,
description: 'ISO-8601 date the contract auto-renews',
},
{
key: 'internal_notes',
visible: false,
},
],
},
{
memoryTypeId: 'mty_invoices',
memoryIds: ['mem_...', 'mem_...'],
metadata: [
{
key: 'renewal_date',
visible: true,
description: 'ISO-8601 date the contract auto-renews',
},
],
},
{
memoryTypeId: 'mty_notes',
includeAll: true,
},
],
},
},
}
```
**`createAgentTask` (`EXPLORE_PRODUCT`)**
```js
{
createAgentTask: {
taskType: 'EXPLORE_PRODUCT',
conversationId: 'cnv_...',
metadata: {
userId: 'usr_...',
},
allowedActions: ['setTheme', 'applyFilter'],
guardRails: {
memoryTypes: [
{
memoryTypeId: 'mty_products',
match: {
tenant_id: 'ten_...',
},
metadata: [
{
key: 'sku',
visible: true,
description: 'Store SKU used to look up the product',
},
{
key: 'cost_price',
visible: false,
},
],
},
{
memoryTypeId: 'mty_faqs',
memoryIds: ['mem_...', 'mem_...'],
},
{
memoryTypeId: 'mty_policies',
includeAll: true,
},
],
},
},
}
```
**`createAgentTaskFileDownload`**
```js
{
createAgentTaskFileDownload: {
agentTaskFileId: 'atf_...',
},
}
```
**`getAgentTaskResponse`**
```js
{
getAgentTaskResponse: {
agentTaskId: 'ata_...',
},
}
```
**`submitManualActionResult`**
```js
{
submitManualActionResult: {
agentTaskId: 'ata_...',
actionCallId: 'aca_...',
},
}
```
See [Guardrails](/docs/sessions/guardrails), [Allowed actions](/docs/sessions/allowed-actions), [Background](/docs/sessions/background), [Examples](/docs/sessions/examples), [Conversation](/docs/sessions/conversation), and the [integration examples](/docs/integration) for how those fields work and for React wiring.
## Security [#security]
Client tokens are scoped to a single session and expire after use or when the session ends. They never expose your secret key. By creating sessions on your server, you maintain full control over who can obtain client tokens and can enforce your own authentication and rate limiting before creating sessions.
# Create agent task file download link (/api/agent-task-files/createAgentTaskFileDownloadLink)
{/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */}
# Get agent task file (/api/agent-task-files/getAgentTaskFile)
{/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */}
# Agent task files (/api/agent-task-files)
Agent tasks can produce files. List or retrieve the task on [Agent tasks](/api/agent-tasks), then fetch file metadata here. Downloading a file uses an [agent task file download session](/api/sessions/createAgentTaskFileDownload).
## The agent task file object [#the-agent-task-file-object]
| Attribute | Type | Description |
| --- | --- | --- |
| `id` | string | The unique identifier of the file. |
| `createdAt` | string | When the file was created. |
| `agentTaskId` | string | The agent task that produced the file. |
| `contentType` | string | File content type. |
| `title` | string | File title. |
# List agent task files (/api/agent-task-files/listAgentTaskFiles)
{/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */}
# Get agent task (/api/agent-tasks/getAgentTask)
{/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */}
# Agent tasks (/api/agent-tasks)
Agent tasks represent asynchronous work such as memory chat, memory search, explore memories, explore product, or chat completions. Create a task by [creating a session](/api/sessions), then list or retrieve the task here. Poll or subscribe to webhooks to track status and results. Files a task produces are documented under [Agent task files](/api/agent-task-files).
## The agent task object [#the-agent-task-object]
| Attribute | Type | Description |
| --- | --- | --- |
| `id` | string | The unique identifier of the agent task. |
| `createdAt` | string | When the agent task was created. |
| `updatedAt` | string | When the agent task was last updated. |
| `status` | string | `IN_PROGRESS`, `SUCCEEDED`, or `FAILED`. |
| `taskType` | string | Kind of task (`MEMORY_CHAT`, `MEMORY_SEARCH`, `EXPLORE_MEMORIES`, `EXPLORE_PRODUCT`, `STREAMING_CHAT_COMPLETION`, `STRUCTURED_CHAT_COMPLETION`). |
| `succeededAt` | string | When the task completed successfully, if applicable. |
| `successResponse` | object | Task-specific success payload when status is `SUCCEEDED`. |
| `failedAt` | string | When the task failed, if applicable. |
| `failureResponse` | object | Error details when status is `FAILED`. |
| `metadata` | object | Custom key-value metadata for the agent task. |
| `conversationId` | string | Conversation this task belongs to, if any. |
| `manualActionResults` | array | Results recorded for any manual actions the task requested. |
# List agent tasks (/api/agent-tasks/listAgentTasks)
{/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */}
# Secret keys (/api/authentication)
The Fency API uses secret keys to authenticate requests. Secret keys can be obtained in the Fency Dashboard.
Your secret keys carry many privileges, so be sure to keep them secure! Do not share your secret keys in publicly accessible areas such as GitHub, client-side code, and so forth.
Authentication to the API is performed via bearer auth. Provide your secret key as `-H "Authorization: Bearer sk_123..."`.
All API requests must be made over HTTPS. Calls made over plain HTTP will fail. API requests without authentication will also fail.
# Create conversation (/api/conversations/createConversation)
{/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */}
# Get conversation (/api/conversations/getConversation)
{/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */}
# Conversations (/api/conversations)
The conversation object groups agent tasks. Create a conversation first, then attach agent tasks by passing that `conversationId` when you create a session. Passing an unknown `conversationId` fails. The title is set from the first attached agent task that has a query.
Use [List conversations](/api/conversations/listConversations) to list all conversations, and [Search conversations](/api/conversations/searchConversations) to filter by metadata.
## The conversation object [#the-conversation-object]
| Attribute | Type | Description |
| --- | --- | --- |
| `id` | string | The unique identifier of the conversation. Prefixed with `cnv_`. |
| `createdAt` | string | When the conversation was created. |
| `title` | string | Conversation title. Null until the first attached agent task with a query sets it. |
| `metadata` | object | Custom key-value metadata for the conversation. |
# List conversations (/api/conversations/listConversations)
{/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */}
# Search conversations (/api/conversations/searchConversations)
{/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */}
# Events (/api/events)
Events are our way of letting you know when something interesting happens in your account. When an interesting event occurs, we create a new event object. For example, when a memory is created, we create an event of type `memory.created`, and when a memory is updated, we create an event of type `memory.updated`.
Events contain different data fields depending on its `type`. For example, a `memory.updated` event contains the given `entity` object (the [memory object](/api/memories)) that was updated.
We have a separate [webhooks](/api/webhooks) system for sending the Event objects directly to an endpoint on your server. You can manage webhooks in your account dashboard.
We only guarantee access to the events through the API and dashboard for 30 days.
## The event object [#the-event-object]
| Attribute | Type | Description |
| --- | --- | --- |
| `id` | string | The unique identifier of the event. |
| `type` | string | The type of the event. |
| `createdAt` | string | The timestamp the event was created. |
| `entity` | object | The object the event is about. Its shape depends on `type`: a [memory object](/api/memories) for `memory.*` events, an [agent task object](/api/agent-tasks) for `agent.task.*` events. |
## Event types [#event-types]
- [`memory.created`](#memorycreated)
- [`memory.updated`](#memoryupdated)
- [`memory.deleted`](#memorydeleted)
- [`agent.task.created`](#agenttaskcreated)
- [`agent.task.updated`](#agenttaskupdated)
## memory.created [#memorycreated]
Event published when a new [memory](/api/memories) is created. The `entity` attribute is the memory that was created.
```json
{
"id": "evt_123e4567e89b12d3a456426614174000",
"type": "memory.created",
"createdAt": "2023-10-01T12:00:00Z",
"entity": {
"id": "mem_cf4fe6caa10b4009a8b103d2d5d64042",
"createdAt": "2024-04-08T17:02:27.887295Z",
"updatedAt": "2024-04-08T17:02:27.887295Z",
"sourceType": "TEXT",
"typeId": "mty_cf4fe6caa10b4009a8b103d2d5d64042",
"title": "Example Title",
"content": "This is the full content of the secret memory.",
"contentParts": 1,
"contentStatus": "SYNCHRONIZED",
"metadata": {
"organization_id": "org_1234567890",
"allowed_roles": [
"Admin",
"Member"
],
"size": 1337,
"is_available": true,
"mean_score": 3.14
},
"customerId": "cus_examplecustomerid000000000000"
}
}
```
## memory.updated [#memoryupdated]
Event published when a [memory](/api/memories) is updated. The `entity` attribute is the memory that was updated.
```json
{
"id": "evt_123e4567e89b12d3a456426614174000",
"type": "memory.updated",
"createdAt": "2023-10-01T12:00:00Z",
"entity": {
"id": "mem_cf4fe6caa10b4009a8b103d2d5d64042",
"createdAt": "2024-04-08T17:02:27.887295Z",
"updatedAt": "2024-04-08T17:02:27.887295Z",
"sourceType": "TEXT",
"typeId": "mty_cf4fe6caa10b4009a8b103d2d5d64042",
"title": "Example Title",
"content": "This is the full content of the secret memory.",
"contentParts": 1,
"contentStatus": "SYNCHRONIZED",
"metadata": {
"organization_id": "org_1234567890",
"allowed_roles": [
"Admin",
"Member"
],
"size": 1337,
"is_available": true,
"mean_score": 3.14
},
"customerId": "cus_examplecustomerid000000000000"
}
}
```
## memory.deleted [#memorydeleted]
Event published when a [memory](/api/memories) is deleted. The `entity` attribute is the memory that was deleted.
```json
{
"id": "evt_123e4567e89b12d3a456426614174000",
"type": "memory.deleted",
"createdAt": "2023-10-01T12:00:00Z",
"entity": {
"id": "mem_cf4fe6caa10b4009a8b103d2d5d64042",
"createdAt": "2024-04-08T17:02:27.887295Z",
"updatedAt": "2024-04-08T17:02:27.887295Z",
"sourceType": "TEXT",
"typeId": "mty_cf4fe6caa10b4009a8b103d2d5d64042",
"title": "Example Title",
"content": "This is the full content of the secret memory.",
"contentParts": 1,
"contentStatus": "SYNCHRONIZED",
"metadata": {
"organization_id": "org_1234567890",
"allowed_roles": [
"Admin",
"Member"
],
"size": 1337,
"is_available": true,
"mean_score": 3.14
},
"customerId": "cus_examplecustomerid000000000000"
}
}
```
## agent.task.created [#agenttaskcreated]
Event published when a new [agent task](/api/agent-tasks) is created (or first observed for delivery). The `entity` attribute is the agent task that was created.
```json
{
"id": "evt_123e4567e89b12d3a456426614174000",
"type": "agent.task.created",
"createdAt": "2023-10-01T12:00:00Z",
"entity": {
"id": "ata_cf4fe6caa10b4009a8b103d2d5d64042",
"createdAt": "2023-10-01T12:00:00Z",
"updatedAt": "2023-10-01T12:00:00Z",
"status": "SUCCEEDED",
"taskType": "MEMORY_CHAT",
"succeededAt": "2023-10-01T12:00:00Z",
"successResponse": {},
"failedAt": "2023-10-01T12:00:00Z",
"failureResponse": {
"message": "Task failed: upstream timeout"
},
"metadata": {
"organization_id": "org_1234567890",
"allowed_roles": [
"Admin",
"Member"
],
"size": 1337,
"is_available": true,
"mean_score": 3.14
},
"conversationId": "cnv_cf4fe6caa10b4009a8b103d2d5d64042",
"manualActionResults": [
{
"actionCallId": "aca_cf4fe6caa10b4009a8b103d2d5d64042",
"status": "APPROVED",
"name": "createIncident",
"input": {},
"reason": "User approved.",
"recordedAt": "2023-10-01T12:05:00Z"
}
]
}
}
```
## agent.task.updated [#agenttaskupdated]
Event published when an [agent task](/api/agent-tasks) is updated (for example status or result changes). The `entity` attribute is the agent task that was updated.
```json
{
"id": "evt_123e4567e89b12d3a456426614174000",
"type": "agent.task.updated",
"createdAt": "2023-10-01T12:00:00Z",
"entity": {
"id": "ata_cf4fe6caa10b4009a8b103d2d5d64042",
"createdAt": "2023-10-01T12:00:00Z",
"updatedAt": "2023-10-01T12:00:00Z",
"status": "SUCCEEDED",
"taskType": "MEMORY_CHAT",
"succeededAt": "2023-10-01T12:00:00Z",
"successResponse": {},
"failedAt": "2023-10-01T12:00:00Z",
"failureResponse": {
"message": "Task failed: upstream timeout"
},
"metadata": {
"organization_id": "org_1234567890",
"allowed_roles": [
"Admin",
"Member"
],
"size": 1337,
"is_available": true,
"mean_score": 3.14
},
"conversationId": "cnv_cf4fe6caa10b4009a8b103d2d5d64042",
"manualActionResults": [
{
"actionCallId": "aca_cf4fe6caa10b4009a8b103d2d5d64042",
"status": "APPROVED",
"name": "createIncident",
"input": {},
"reason": "User approved.",
"recordedAt": "2023-10-01T12:05:00Z"
}
]
}
}
```
# API Reference (/api)
The Fency API is organized around [REST](http://en.wikipedia.org/wiki/Representational_State_Transfer). Our API has predictable resource-oriented URLs, accepts [form-encoded](https://en.wikipedia.org/wiki/POST_(HTTP)#Use_for_submitting_web_forms) request bodies, returns [JSON-encoded](https://www.json.org/json-en.html) responses, and uses standard HTTP response codes, authentication, and verbs.
The Fency API doesn't support bulk updates. You can work on only one object per request.
Endpoint reference lives under each resource: [Memories](/api/memories), [Memory types](/api/memory-types), [Sessions](/api/sessions), and [Agent tasks](/api/agent-tasks).
# Create file memory (/api/memories/createFileMemory)
{/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */}
# Create metadata memory (/api/memories/createMetadataMemory)
{/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */}
# Create text memory (/api/memories/createTextMemory)
{/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */}
# Create URL memory (/api/memories/createUrlMemory)
{/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */}
# Delete memory (/api/memories/deleteMemory)
{/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */}
# Get memory (/api/memories/getMemory)
{/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */}
# Get memory content (/api/memories/getMemoryContent)
{/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */}
# Memories (/api/memories)
The memory object represents stored data that can be created, updated, and retrieved. Memories support custom metadata and content that can be used to persist information for your application.
You can create memories by text, URL, or file upload. See [Uploading files for memories](/api/memory-uploads/uploading-files) and [Memory uploads](/api/memory-uploads) for the file flow. Use [Memory download links](/api/memory-download-links) to retrieve a file later, and [Memory bulk jobs](/api/memory-bulk-jobs) to upsert or delete many memories at once.
## The memory object [#the-memory-object]
| Attribute | Type | Description |
| --- | --- | --- |
| `id` | string | The unique identifier of the memory. |
| `createdAt` | string | When the memory was created. |
| `updatedAt` | string | When the memory was last updated. |
| `sourceType` | string | The source type of the memory (`TEXT`, `URL`, `FILE`, or `METADATA`). |
| `typeId` | string | The type identifier for the memory. |
| `title` | string | The title of the memory. |
| `content` | string | The full content of the memory. |
| `contentParts` | number | The number of content parts in the memory. |
| `contentStatus` | string | One of `EMPTY`, `SYNCHRONIZING`, `SYNCHRONIZED`, or `SYNCHRONIZATION_ERROR`. |
| `metadata` | object | Custom key-value metadata for the memory. |
# List memories (/api/memories/listMemories)
{/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */}
# Update memory (/api/memories/updateMemory)
{/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */}
# Create memory bulk job (/api/memory-bulk-jobs/createMemoryBulkJob)
{/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */}
# Get memory bulk job (/api/memory-bulk-jobs/getMemoryBulkJobStatus)
{/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */}
# Memory bulk jobs (/api/memory-bulk-jobs)
Bulk jobs upsert or delete many memories asynchronously. They are only available for METADATA memories. Create a job, then poll its status until it finishes. Memories themselves stay on [Memories](/api/memories).
## The bulk job [#the-bulk-job]
| Attribute | Type | Description |
| --- | --- | --- |
| `jobId` | string | The unique identifier of the bulk job. |
| `type` | string | The bulk job type. |
| `status` | string | Current job status. |
| `totalItems` | number | Total number of items in the job. |
| `processedItems` | number | Number of items processed so far. |
| `memoryTypeId` | string | Memory type the job applies to. |
| `result` | object | Per-item outcome counts when the job has results (`created`, `updated`, `skipped`, `deleted`, `notFound`). |
| `errorMessage` | string | Error message when the job failed. |
| `createdAt` | string | When the job was created. |
| `completedAt` | string | When the job finished, if applicable. |
# Create memory download link (/api/memory-download-links/createMemoryDownloadLink)
{/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */}
# Memory download links (/api/memory-download-links)
Create a short-lived URL to download a file memory. Creating and uploading the file is documented under [Memories](/api/memories) and [Memory uploads](/api/memory-uploads).
## The download link [#the-download-link]
| Attribute | Type | Description |
| --- | --- | --- |
| `downloadLink` | string | URL that can be used to download the file. |
# Create memory type (/api/memory-types/createMemoryType)
{/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */}
# Delete memory type (/api/memory-types/deleteMemoryType)
{/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */}
# Get memory type (/api/memory-types/getMemoryType)
{/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */}
# Memory types (/api/memory-types)
Memory types define the schema for memories, including custom metadata fields and relations. Use memory types to organize and structure the data stored in your memories.
## The memory type object [#the-memory-type-object]
| Attribute | Type | Description |
| --- | --- | --- |
| `id` | string | The unique identifier of the memory type. |
| `createdAt` | string | When the memory type was created. |
| `name` | string | The name of the memory type. |
| `description` | string | A description of the memory type. |
| `type` | string | The kind of memory type. |
| `metadataSource` | object | Optional identity and updated-at metadata key mapping. |
| `metadataFields` | array | Metadata field definitions. Each field has `name`, `description`, and `fieldType` (`STRING`, `STRING_LIST`, `BOOLEAN`, `INTEGER`, `DECIMAL`). |
# List memory types (/api/memory-types/listMemoryTypes)
{/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */}
# Create memory upload (/api/memory-uploads/createMemoryUpload)
{/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */}
# Memory uploads (/api/memory-uploads)
File memories are created on [Memories](/api/memories), then uploaded with a presigned POST. See [Uploading files for memories](/api/memory-uploads/uploading-files) for the full flow. To retrieve a file later, use [Memory download links](/api/memory-download-links).
## The upload [#the-upload]
| Attribute | Type | Description |
| --- | --- | --- |
| `awsS3PostRequest` | object | Fields and URL required to POST the file to S3. |
### S3 POST fields [#s3-post-fields]
| Attribute | Type | Description |
| --- | --- | --- |
| `amzDate` | string | The `x-amz-date` value to include in the upload. |
| `amzSignature` | string | The `x-amz-signature` value to include in the upload. |
| `amzAlgorithm` | string | The `x-amz-algorithm` value to include in the upload. |
| `amzCredential` | string | The `x-amz-credential` value to include in the upload. |
| `policy` | string | The policy document for the upload. |
| `key` | string | The object key the file will be stored under. |
| `uploadUrl` | string | The URL to POST the file to. |
| `sessionToken` | string | The `x-amz-security-token` value to include in the upload. |
# Uploading files for memories (/api/memory-uploads/uploading-files)
This guide walks through the full process of uploading a file to create a memory in Fency.ai. File memories can then be used as context in [Data exploration](/docs/integration/data-exploration).
The upload flow consists of three steps: creating an empty file memory, obtaining a presigned upload URL, and uploading the file directly to that URL. These calls are made from your server using your [secret key](/docs/secret-keys).
## Step 1: Create a memory [#step-1-create-a-memory]
Call [Create file memory](/api/memories/createFileMemory) (`POST /v1/memories` with `sourceType: "FILE"`) to create an empty memory record. This returns a `memoryId` you will use in the next step.
## Step 2: Create an upload for the memory [#step-2-create-an-upload-for-the-memory]
Call [Create memory upload](/api/memory-uploads/createMemoryUpload) (`POST /v1/memories/:id/uploads`) with the file's `fileName`, `fileSize`, and `mimeType`. This returns an `awsS3PostRequest` object containing a presigned S3 upload URL along with the required form fields.
## Step 3: Upload the file [#step-3-upload-the-file]
Post a `FormData` payload containing all the returned S3 fields plus the file itself to the presigned `uploadUrl`.
```js
// Step 1: Create a memory with sourceType FILE
const createMemoryResponse = await fetch('https://api.fency.ai/v1/memories', {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.FENCY_SECRET_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
memoryTypeId: 'mty_...',
sourceType: 'FILE',
title: 'My document',
}),
})
const { id: memoryId } = await createMemoryResponse.json()
// Step 2: Create an upload for the memory to get a presigned URL
const createUploadResponse = await fetch(
`https://api.fency.ai/v1/memories/${memoryId}/uploads`,
{
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.FENCY_SECRET_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
fileName: file.name,
fileSize: file.size,
mimeType: file.type,
}),
}
)
const { awsS3PostRequest } = await createUploadResponse.json()
// Step 3: Upload the file directly to the presigned URL
const formData = new FormData()
formData.append('key', awsS3PostRequest.key)
formData.append('policy', awsS3PostRequest.policy)
formData.append('x-amz-algorithm', awsS3PostRequest.amzAlgorithm)
formData.append('x-amz-credential', awsS3PostRequest.amzCredential)
formData.append('x-amz-date', awsS3PostRequest.amzDate)
formData.append('x-amz-signature', awsS3PostRequest.amzSignature)
formData.append('x-amz-security-token', awsS3PostRequest.sessionToken)
formData.append('file', file)
await fetch(awsS3PostRequest.uploadUrl, {
method: 'POST',
body: formData,
})
```
## Knowing when the memory is ready [#knowing-when-the-memory-is-ready]
After the upload completes, Fency processes the file asynchronously. Once the memory is ready to use, a `memory.updated` webhook event is sent to your registered webhook endpoint. See [Events](/api/events) in the API reference for the event payload.
For full details on the request parameters, see [Create file memory](/api/memories/createFileMemory) and [Create memory upload](/api/memory-uploads/createMemoryUpload).
# Pagination (/api/pagination)
All top-level API resources have support for bulk fetches through "list" API methods. For example, you can list `Memories`. These list API methods share a common structure and accept the parameters `nextPageToken` and `previousPageToken`.
The `nextPageToken` parameter returns the items listed *after the last item* in the current list.
The `previousPageToken` parameter returns the items listed *before the first item* in the current list.
These parameters are mutually exclusive. You can use either the `nextPageToken` or `previousPageToken` parameter, but not both simultaneously.
# Create agent task file download session (/api/sessions/createAgentTaskFileDownload)
{/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */}
# Create explore memories session (/api/sessions/createExploreMemories)
{/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */}
# Create explore product session (/api/sessions/createExploreProduct)
{/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */}
# Create memory chat session (/api/sessions/createMemoryChat)
{/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */}
# Create memory search session (/api/sessions/createMemorySearch)
{/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */}
# Create stream session (/api/sessions/createStream)
{/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */}
# Create streaming chat completion session (/api/sessions/createStreamingChatCompletion)
{/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */}
# Create structured chat completion session (/api/sessions/createStructuredChatCompletion)
{/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */}
# Create get agent task response session (/api/sessions/getAgentTaskResponse)
{/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */}
# Sessions (/api/sessions)
Sessions contain short-lived tokens used to authenticate client-side operations such as streaming, chat completions, and agent tasks. Create a session on your server using your secret key, then pass the returned `clientToken` to your client.
`POST /v1/sessions` is one endpoint. Each creatable session type has its own operation page:
- [Create stream session](/api/sessions/createStream)
- [Create streaming chat completion session](/api/sessions/createStreamingChatCompletion)
- [Create structured chat completion session](/api/sessions/createStructuredChatCompletion)
- [Create memory chat session](/api/sessions/createMemoryChat)
- [Create memory search session](/api/sessions/createMemorySearch)
- [Create explore memories session](/api/sessions/createExploreMemories)
- [Create explore product session](/api/sessions/createExploreProduct)
- [Create agent task file download session](/api/sessions/createAgentTaskFileDownload)
- [Create get agent task response session](/api/sessions/getAgentTaskResponse)
- [Create submit manual action result session](/api/sessions/submitManualActionResult)
## The session object [#the-session-object]
| Attribute | Type | Description |
| --- | --- | --- |
| `id` | string | The unique identifier for the session. |
| `createdAt` | string | When the session was created. |
| `type` | string | `CREATE_STREAM`, `CREATE_AGENT_TASK`, `CREATE_AGENT_TASK_FILE_DOWNLOAD`, `GET_AGENT_TASK_RESPONSE`, or `SUBMIT_MANUAL_ACTION_RESULT`. |
| `clientToken` | string | A short-lived token used to authenticate client-side operations. Pass this to your client; it should never be stored or logged. |
# Create submit manual action result session (/api/sessions/submitManualActionResult)
{/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */}
# Webhooks (/api/webhooks)
Webhooks are a way to receive real-time notifications from the API when a specific event occurs. For example, you can receive an event when a memory is created, updated, or deleted.
To start receiving webhook events, create and register a webhook through the dashboard.
## Event types [#event-types]
See [Events](/api/events) for a list of all available event types.
## Security [#security]
A cryptographically secure secret should be generated and persisted on your own server. When you create a webhook in the dashboard, the secret must be provided.
We will use the secret to generate a signature on each webhook request, provided in the `x-fency-signature` header. See [verifying the payload signature](#verifying-the-payload-signature) for instructions on using the secret to verify that the request originated from Fency.
## Verifying the payload signature [#verifying-the-payload-signature]
Requests to your endpoint will bear an `x-fency-signature` header verifying that the request has come from us.
The signature is the HMAC hex digest of the payload, where the `algorithm` is `sha256`, the `key` is your own secret, and the `payload` is the raw UTF-8 request body received from us. The signature is then prefixed with `sha256=`.
A delivery of `{"payload":"example"}` signed with `example-secret` would have an `x-fency-signature` of `sha256=c395de9ac90297578092d5d0394dc8def76ab5561ec1717dadb6aece0f40bd04`.
```js
const secret = "example-secret";
const payload = req.body; // {"payload":"example"}
// Read signature from request HTTP header
const fencySignature = Buffer.from(req.get("x-fency-signature"), "utf8");
// Compute signature using your secret and the request payload
const hmac = crypto.createHmac("sha256", secret);
const digest = Buffer.from("sha256=" + hmac.update(payload).digest("hex"), "utf8");
// Check whether they match, using timing-safe equality (don't use ==)
if (!crypto.timingSafeEqual(digest, fencySignature)) {
throw new Error("Signature invalid");
}
```