Naalya Handbook

The AI Assistant

The streaming chat assistant, the knowledge base that grounds it, and the social agent that answers inbound questions.

The Hub ships a built-in AI assistant — Naalya AI, "Rover" — that staff can chat with in plain language. But the chat box is only the part you see. Behind it sit two more pieces that make the assistant actually useful: a knowledge base of documents that ground its answers, and a social agent that fields questions arriving from WhatsApp and Telegram. Think of them as one system with three faces — the chat you talk to, the knowledge it draws on, and the inbox it watches.

All three lean on the data layer for their CRUD work and on the bearer token for auth. The chat itself is the odd one out — it doesn't use the generated client at all. It streams.

The chat component

The chat lives in src/components/ai-chat/staff-ai-chat.tsx and is built on the ai SDK (@ai-sdk/react). The heavy lifting — sending a message, receiving a token-by-token reply, tracking whether the model is mid-thought — all comes from one hook, useChat. You don't manage a message array, a loading flag, or a fetch by hand. The hook owns all of it.

The visible primitives — the scrollable conversation, each message bubble, the prompt box — come from a small kit in src/components/ai-elements (Conversation, Message, PromptInput). Replies render through streamdown, so Markdown, code blocks, math, and even mermaid diagrams arrive formatted as they stream in, and the input itself is a Tiptap editor rather than a plain textarea.

Streaming with useChat

Here's the whole connection. useChat is pointed at the backend through a transport — the object that knows where to send messages and how to authenticate:

src/components/ai-chat/staff-ai-chat.tsx
const userTokens = getStoredAuth()
const { messages, sendMessage, status } = useChat({
  transport: new DefaultChatTransport({
    api: `${import.meta.env.VITE_API_URL}/api/v1/naalya-ai/admin/chat`,
    headers: {
      Authorization: `Bearer ${userTokens.token}`,
    },
  }),
})

The transport is where the assistant meets the rest of the Hub. The api is the streaming endpoint, and the Authorization header carries the same bearer token every other request uses — pulled straight from storage with getStoredAuth(). The backend reads that token, knows which staff member is asking, and handles the actual retrieval and model call.

The hook hands you three things. messages is the running conversation, sendMessage posts a new one, and status is the state machine that drives the UI:

src/components/ai-chat/staff-ai-chat.tsx
const isStreaming = status === 'streaming'
const isReady = status === 'ready'

const handleSubmit = (message: PromptInputMessage) => {
  if (status === 'ready' && message.text.trim()) {
    sendMessage({ text: message.text })
    setInput('')
  }
}

Only send when status is ready

The submit handler guards on status === 'ready' before it sends. That single check is what stops a junior dev from firing a second message while the model is still streaming the first — the send button is also disabled on status !== 'ready'. Trust the status, not a hand-rolled boolean.

As the reply streams, status flips to 'streaming' and a loader appears under the conversation; once the model finishes it settles back to 'ready' and the prompt re-enables. You never poll and you never await a response — you read status and messages, and the UI follows.

The knowledge base

A model is only as good as what it knows about your school. The knowledge base is how staff teach it. It's a set of CRUD-able Markdown documents — fees, term dates, admissions policy — living under src/queries/knowledge-base. The backend handles the retrieval; the frontend's job is just to let staff write and edit those documents.

Because the content is free-form and round-trips through an API, the query layer normalizes every document on the way in so the rest of the app can trust the shape. The key move is coercing content to a string — Markdown is text, and anything that isn't text becomes a safe empty placeholder:

src/queries/knowledge-base/knowledge-base.query.ts
function normalizeContent(value: unknown): string {
  // Content is Markdown — pass strings through; coerce anything else to empty.
  return typeof value === 'string' ? value : EMPTY_KNOWLEDGE_BASE_CONTENT
}

readonly list = async () => {
  const data = await this.exec(this.op.listKnowledgeBaseDocuments())
  return data.map(normalizeKnowledgeBaseDocument)
}

Every read path runs through normalizeKnowledgeBaseDocumentlist, getById, and the value returned by create and update all pass through it. So no matter how the API responds, a component always receives a document whose content is a string it can render or drop into the editor.

Normalize on the boundary, not in the component

Notice where the coercion happens — in the query module, the moment data crosses into the app. Pages never have to ask "is content maybe an object today?" That defensive check lives in exactly one place, which is the whole point of the generated client sitting between you and the API.

The social agent

Parents don't always open the Hub — they message the school on WhatsApp or Telegram. The social agent is the assistant working those channels. An inbound question arrives, the AI drafts an answer, and staff get a chance to review it before it goes out. Two query modules cover this: social-agent-question for the questions themselves, and social-connection for the channels.

The single most important action is the override. Staff read the AI's draft, and if it's wrong or needs a human touch, they replace it — answer(id, response) sends the final reply that the customer actually receives:

src/queries/social-agent-question/social-agent-question.query.ts
readonly answer = async (id: string, response: string) =>
  this.exec(this.op.answerSocialAgentQuestion({ id }, { response }))

That's the human-in-the-loop guarantee in one line: a person can always have the last word. The list method backs the review queue, and it can be filtered by status so staff see only what's still waiting on them.

The other half is per-channel control. On a social-connection you can flip the AI on or off for a channel, or hand it a supervisor — a staff member who gets looped in:

src/queries/social-connection/social-connection.query.ts
readonly setAi = async (
  provider: ConnectSocialAccountBody['provider'],
  enabled: boolean,
) => this.exec(this.op.setSocialAccountAi({ provider }, { enabled }))

So a school can let the agent answer FAQs on WhatsApp automatically, but route a noisier channel entirely to a human — the same assistant, dialed up or down per connection.

The cast on setSupervisor is deliberate

setSupervisor casts its body with as unknown as SetSocialAccountSupervisorBody. That's not sloppiness — the generated DTO still types supervisorId as a Record while the backend catches up, even though the API accepts a plain string or null. The comment in the source spells it out so the next person doesn't "fix" it.

Where to go next

On this page