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"}| Event | Fields | Do |
|---|---|---|
text | delta | Append to the assistant's message |
tool | name, status | Optional "Searching..." hint |
error | message | Show it; done follows |
done | Stop 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:
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:
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:
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.