Naalya Handbook
AI System

What happens on a chat turn

Follow one staff message from the Hub through the worker graph, an RPC tool call, and the streamed reply.

A staff member types a sentence and hits send. This page is that turn, in order — so when something is missing (wrong campus, a tool the model never reaches, a permission error in the reply) you know which hop to open.

You do not need this page to add a tool. You need it the first time a tool "doesn't fire" and you are staring at four processes.

1. The Hub opens a stream

The Education Hub does not call api.op for chat. It streams. useChat (and the Rover stream hook) posts to the naalya-ai chat endpoint with the bearer token every other request already uses. The server authenticates the staff member, mints the runtime context, and hands the turn to the worker. The HTTP response is a stream of events; the Hub renders them as they arrive.

The context is the whole security story for the turn. It is built on the server from the JWT and live database rows, then passed to LangGraph as runtimeContext. The model never supplies it.

What goes in:

  • WhouserDetails: name, email, user id, profile id, user type, school, optional campus.
  • WhererunMode: school or campus (campus mode always carries a campus id and name; a campus mode with no campus is a type error).
  • What they may douserPermissions, a flattened CASL snapshot. Advisory only. It stops Rover offering work that will fail. It is never the authorization decision — that is rebuilt on every RPC call.
  • Which specialists exist this turncapabilities: class teacher, subject teacher, director of studies.
  • The calendar — current academic year and terms, so "this term" does not need a lookup.
  • The teaching map — class/subject assignments, omitted for non-teachers rather than sent empty.
  • This message's attachments — not the whole thread.

Run mode is not an authorization boundary

A client that lies about school vs campus only changes what Rover offers. Every RPC result is still filtered by a freshly rebuilt CASL ability. The server validates mode anyway, because a wrong mode makes Rover confidently misdescribe its own answers.

2. The worker builds the graph

createRoverGraph runs per turn. Nothing about the caller's tools or prompt is cached across messages — if their roles changed five seconds ago, the next turn already reflects it.

The graph is createDeepAgent with:

  • Rover's tools: buildAgentTools — RPC proxies for this run mode, plus local tools, plus backend-bound tools (save_artifact, …) when a sandbox is mounted.
  • Skills from skillSourcesForWireId('rover')/skills/general/ then /skills/rover/.
  • Role subagents from selectSubagents only when capabilities says they exist. Each subagent is its own compiled graph, with its own tools (buildRoleTools) and skills. They do not inherit Rover's.

The system prompt is assembled, not hand-maintained as a tool list: supervisor copy + a tool inventory derived from the same array the graph receives + a delegate roster derived from the same subagents array. If a tool is missing from the inventory, it is missing from the graph — they cannot drift.

3. The model calls a tool

Rover (or a subagent) picks a tool by namecreate_department, get_current_date. What happens next depends on which kind it is.

Local. The handler runs in the worker. No RabbitMQ. Used for a clock, asking the staffer a question, listing the agent map, sandbox files. See Adding a local tool.

RPC. The generic proxy in build-agent-tools.ts does the same three things for every tool. The helpers it calls — resolveIdentity, resolveCallScope, toRpcProxy, AgentToolService.call — are the RPC hop, in that order.

  1. Take identity and call scope from the runtime context — never from model arguments.
  2. toolCaller.call(definition.event, { identity, scope, args }) over the agent-tool queue.
  3. Stringify { data, scopeContext } or { error } back to the model.

The event (create.department) is the RabbitMQ message pattern. The name (create_department) is what the model and the Hub label registry see. They are allowed to differ; keep the event verb.subject and the name verb_subject. How those fields are declared: Defining a tool.

4. The server executes

AgentToolsController listens with @MessagePattern(tool.event). Every handler funnels through execute(). The numbered list below is the outline; every helper on this hop — payload shape, both postures, audit — is The RPC hop.

  1. Reject a payload with no identity.
  2. Parse args against the shared Zod schema.
  3. Fail closed if the identity has no tenant (NO_TENANT_SCOPE_ERROR).
  4. Rebuild the CASL ability inside tenant scope — no HTTP middleware ran on this path.
  5. Run the posture: casl goes through runInToolScope (permission gate + tenant frame); relationship goes through runInRelationshipToolScope (the domain service asserts ownership itself).
  6. On a successful write, emit exactly one audit row. HTTP @Audit interceptors do not run on RPC.

The service method is the same one the REST controller calls. The tool does not reimplement the domain.

5. The model reads the envelope

Both kinds of tool return the same shape, so the model has one contract:

type AgentToolResult<T> =
  | { data: T; scopeContext: string }
  | { error: string };

scopeContext is a sentence about how filtered the data is — school-wide vs this campus. The prompt tells the model to qualify its language from that field, so a campus-scoped user hears "across your campus" rather than a fake whole-school number.

A denied permission is { error: 'Permission denied…' }, not an HTTP 403. The model can explain it; the Hub hides failed tool rows from the activity timeline and lets Rover's reply carry the news.

6. The Hub narrates

As tool calls stream, the Hub looks up the tool name in src/components/rover/activity/tool-labels.ts and draws a line ("Creating a department…"). No label means fallback copy derived from the verb — awkward for verbs like upsert or recompute. Treat the label as part of shipping the tool. The RPC how-to includes that step.


If something looks wrong

SymptomFirst place to look
Model never calls your toolDescription, scope vs run mode, roles vs who is chatting — placement on the contract
Tool called, { error: Permission denied }Handler gate vs the caller's CASL rules
Tool called, empty listTenant / campus / year filters on the service, not the tool wrapper
Wrong campus's dataEnvelope pin: payload.scope.campusId must win over model campusId
Chat line missing in the HubTool label map — name must match create_department, not create.department
Skill never loadsFolder name = frontmatter name, persona folder, webpack copy in prod

Where to go next

On this page