Naalya Developers

Chat

Stream the assistant's reply into your own UI.

Stateless: send the whole conversation each turn (the last 50 messages are read), get the reply back as Server-Sent Events. The assistant knows the school's name, campuses and contacts, searches the public knowledge base, and when admissions is open can send an enquiry after the visitor confirms.

Request

POST /api/v1/public-ai/chat with X-API-Key and Content-Type: application/json.

{
  "messages": [{ "role": "user", "content": "How much are the Senior 1 fees?" }]
}

messages: 1 to 50, oldest first, role is user or assistant, content up to 4,000 characters, last one from the user.

curl -N "$NAALYA_API/api/v1/public-ai/chat" \
  -H "X-API-Key: $NAALYA_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"messages":[{"role":"user","content":"How much are the Senior 1 fees?"}]}'

Response

200, text/event-stream. One JSON event per data: line, blank line between events:

data: {"type":"tool","name":"search_knowledge_base","status":"started"}

data: {"type":"tool","name":"search_knowledge_base","status":"finished"}

data: {"type":"text","delta":"Term one fees for Senior 1 are"}

data: {"type":"text","delta":" UGX 1,200,000."}

data: {"type":"done"}
EventFieldsDo
textdeltaAppend to the assistant's message
toolname, statusOptional "Searching..." hint
errormessageShow it; done follows
doneStop reading

EventSource cannot send a body or a header, so read the stream with fetch. This splits chunks into events and carries the unfinished tail:

parse-sse.ts
export type ChatEvent =
  | { type: 'text'; delta: string }
  | { type: 'tool'; name: string; status: 'started' | 'finished' }
  | { type: 'done' }
  | { type: 'error'; message: string }

// Splits a stream chunk into complete `data:` events; `rest` is the unfinished tail.
export function parseSseChunk(chunk: string, carry = '') {
  const text = carry + chunk
  const parts = text.split('\n\n')
  const rest = parts.pop() ?? ''
  const events: Array<ChatEvent> = []
  for (const part of parts) {
    for (const line of part.split('\n')) {
      if (line.startsWith('data: ')) events.push(JSON.parse(line.slice(6)))
    }
  }
  return { events, rest }
}

React hook

From the package:

import { useNaalyaChat } from '@naalya/chat/react'

const { messages, send, busy, error, config, reset } = useNaalyaChat({
  apiKey: 'sk_nly_...',
})

Rendering it is a list and a form:

SchoolChat.tsx
import { useState } from 'react'
import { useNaalyaChat } from '@naalya/chat/react'

export function SchoolChat() {
  const { messages, send, busy, error } = useNaalyaChat({ apiKey: 'sk_nly_...' })
  const [draft, setDraft] = useState('')

  return (
    <div>
      <ul>
        {messages.map((m, i) => (
          <li key={i} data-role={m.role}>
            {m.content}
          </li>
        ))}
      </ul>
      {error && <p role="alert">{error}</p>}
      <form
        onSubmit={(e) => {
          e.preventDefault()
          if (!draft.trim() || busy) return
          send(draft.trim())
          setDraft('')
        }}
      >
        <input
          value={draft}
          onChange={(e) => setDraft(e.target.value)}
          placeholder="Ask about the school"
        />
        <button disabled={busy}>Send</button>
      </form>
    </div>
  )
}

Without the package

The same hook in one file, for a site that does not bundle dependencies. It holds the conversation, appends text deltas and aborts on unmount:

use-school-chat.ts
import { useCallback, useEffect, useRef, useState } from 'react'
import { parseSseChunk } from './parse-sse'

const NAALYA_API = 'https://api.example' // the base URL from the Overview page
const API_KEY = 'sk_nly_...' // a publishable key, created in the Hub

export type Message = { role: 'user' | 'assistant'; content: string }

export function useSchoolChat() {
  const [messages, setMessages] = useState<Array<Message>>([])
  const [busy, setBusy] = useState(false)
  const [error, setError] = useState<string | null>(null)
  const abort = useRef<AbortController | null>(null)

  // Stop the stream if the component unmounts mid-reply.
  useEffect(() => () => abort.current?.abort(), [])

  const send = useCallback(
    async (content: string) => {
      // The API reads the last 50 messages; keep the same window here.
      const history = [...messages, { role: 'user' as const, content }].slice(
        -50,
      )
      setMessages([...history, { role: 'assistant', content: '' }])
      setBusy(true)
      setError(null)
      abort.current = new AbortController()
      try {
        const res = await fetch(`${NAALYA_API}/api/v1/public-ai/chat`, {
          method: 'POST',
          headers: { 'content-type': 'application/json', 'x-api-key': API_KEY },
          body: JSON.stringify({ messages: history }),
          signal: abort.current.signal,
        })
        if (!res.ok || !res.body)
          throw new Error(`Request failed: ${res.status}`)
        const reader = res.body.pipeThrough(new TextDecoderStream()).getReader()
        let rest = ''
        for (;;) {
          const { value, done } = await reader.read()
          if (done) break
          const parsed = parseSseChunk(value, rest)
          rest = parsed.rest
          for (const event of parsed.events) {
            if (event.type === 'text') {
              setMessages((m) => {
                const last = m[m.length - 1]
                return [
                  ...m.slice(0, -1),
                  { ...last, content: last.content + event.delta },
                ]
              })
            } else if (event.type === 'error') {
              setError(event.message)
            }
          }
        }
      } catch (e) {
        if (!(e instanceof DOMException && e.name === 'AbortError'))
          setError('Could not reach the assistant.')
      } finally {
        setBusy(false)
      }
    },
    [messages],
  )

  return { messages, send, busy, error }
}

History lives in the browser

Keep messages in sessionStorage to survive a reload. The API stores nothing.

On this page