Fency.ai

Structured chat completion

Extract structured JSON data from text using a Zod schema.

This example demonstrates structured output extraction where free-form text is converted to validated JSON that matches a Zod schema. The model returns a complete structured response with no intermediate text events.

Prerequisites

You need a publishable key for the React SDK and a secret key for your server routes. This example uses sessions to authenticate the client.

The example uses Clerk for user authentication. That is part of the app shell and not specific to Fency.

Server session routes

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

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

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

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: {} }),
  })
  const data = await response.json()
  return NextResponse.json(data, { status: response.status })
}

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

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

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 },
      },
    }),
  })
  const data = await response.json()
  return NextResponse.json(data, { status: response.status })
}

Extraction schema

Define the extraction schema using Zod in extractionSchema.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<typeof extractionSchema>

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

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

import { z } from 'zod'

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

Load the Fency client and wrap your component tree with FencyProvider in page.tsx:

'use client'

import { loadFency } from '@fencyai/js'
import { FencyProvider } from '@fencyai/react'
import { Extractor } from './components/Extractor'
import { sessionClientTokenSchema } from './sessionClientTokenSchema'

const fency = loadFency({
  publishableKey: process.env.NEXT_PUBLIC_FENCY_PUBLISHABLE_KEY!,
})

async function fetchCreateStreamClientToken() {
  const res = await fetch(
    '/structured-chat-completion/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 StructuredChatCompletionPage() {
  return (
    <FencyProvider
      fency={fency}
      fetchCreateStreamClientToken={fetchCreateStreamClientToken}
    >
      <Extractor />
    </FencyProvider>
  )
}

Use useAgentTasks and createAgentTask to extract structured data in hooks/useStructuredExtraction.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(
    '/structured-chat-completion/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 useStructuredExtraction() {
  const [isSubmitting, setIsSubmitting] = useState(false)
  const [latestResult, setLatestResult] = useState<Extraction | null>(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' &&
        response.response.taskType === 'StructuredChatCompletion'
      ) {
        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:

import { Alert, Text, Title } from '@mantine/core'
import { AgentTaskProgress } from '@fencyai/react'
import { useStructuredExtraction } from '../hooks/useStructuredExtraction'
import { ExtractionForm } from './ExtractionForm'
import { RecordCard } from './RecordCard'
import { SchemaPreview } from './SchemaPreview'

export function Extractor() {
  const { latestTask, latestResult, isSubmitting, extract } =
    useStructuredExtraction()

  return (
    <div className="mx-auto flex w-full max-w-3xl flex-col gap-6 px-4 py-6">
      <div>
        <Title order={1} size="h4">
          Structured chat completion
        </Title>
        <Text size="sm" c="dimmed">
          Paste free text. Fency returns JSON that matches the Zod schema.
          There are no incremental text events, only a completed result.
        </Text>
        <div className="mt-2">
          <SchemaPreview />
        </div>
      </div>

      <ExtractionForm isSubmitting={isSubmitting} onExtract={extract} />

      {latestTask?.error ? (
        <Alert color="red">{latestTask.error.message}</Alert>
      ) : latestTask ? (
        <AgentTaskProgress agentTask={latestTask} />
      ) : null}

      {latestResult ? (
        <RecordCard title="Latest result" record={latestResult} />
      ) : null}
    </div>
  )
}

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

Clone the repository and install dependencies:

git clone https://github.com/fencyai/fency-react-examples.git
cd fency-react-examples
npm install

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

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):

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

Start the development server:

npm run dev

Open http://localhost:3000/structured-chat-completion. Sign up from the header, then paste free text to extract structured JSON matching the schema.

On this page