Naalya Handbook
AI System

The shared contract

What the worker and server agree on — the runtime context minted at dispatch, the identity that rides every call, and the request/result envelope.

An RPC tool is one object that two processes must agree on. The worker wraps it in a LangChain proxy and shows the model its JSON Schema. The server validates the payload against the same Zod schema and runs the handler. That object lives in @app/shared/naalya-ai and touches neither RabbitMQ nor CASL.

Local tools do not use this contract — they use LangChain's tool() inside the worker. See Adding a local tool.

The three parts

PartWhat it coversWhere
The envelopeWhat the server mints per turn and what crosses the wire: runtime context, identity, call scope, request/result.This page
The definitiondefineAgentTool and its eight fields — how one tool is declared.Defining a tool
The registryRPC_AGENT_TOOLS, the types derived from it, and who is offered which tool.The tool registry
libs/shared/src/naalya-ai/
agent-tools.contract.ts    runtime context, identity, request/result envelopes
tools/define-agent-tool.ts the identity helper that preserves literals
tools/index.ts             RPC_AGENT_TOOLS, toolsForMode, toolsForRole
tools/mutating-verb.ts     isMutatingEvent, KNOWN_* verb lists
tools/<domain>.tool.ts     one defineAgentTool per operation
agent-map.ts               buildAgentMap, eligibleAgentRoles, search
naalya-ai.types.ts         AgentToolCaller

Runtime context

At dispatch the server mints everything the graph needs about this caller and this moment, then hands it to LangGraph as runtime.context. The model never supplies it; tool proxies read identity from it. Schema: agentRuntimeContextSchema.

FieldWhat it is
userDetailsWho is chatting — name, email, user id, profile id, user type, school, optional campus. Parsed by agentUserDetailsSchema.
runModeschool or { mode: 'campus', campus: { id, name } }. A campus mode with no campus is a type error (agentRunModeSchema is a discriminated union).
currentTimez.coerce.date() because JSON turns a Date into a string across RMQ. The default is a factory, not new Date() at module load.
timeZoneIANA name from school config (Africa/Kampala). Optional — omit rather than guess.
userPermissionsFlattened CASL snapshot. Advisory only — see Not a gate.
capabilities{ isClassTeacher, isSubjectTeacher, isDirectorOfStudies }. Defaults all false so old payloads still parse.
currentAcademicYearName, year, optional campusAcademicYearId. Omitted when no current year is configured.
termsCurrent year's terms, ordered by termNumber. Empty when there is no pinned campus.
teachingMapClass/subject assignments. Omitted for non-teachers — presence itself is a capability signal.
attachmentsFiles on this message, not the whole thread.

AgentRuntimeContext is the parsed type; AgentRuntimeContextInput is the wire shape (dates still strings, defaults not applied). Anything reading a field runs it through the schema first — the worker helper is resolveRuntimeContext.

Identity

libs/shared/src/naalya-ai/agent-tools.contract.ts
type AgentIdentity = {
  sub: string
  email: string
  type: UserType
  profileId: string
  schoolId?: string
  campusId?: string
}

const identityFromUserDetails = (details: AgentUserDetails): AgentIdentity => ({
  sub: details.userId,
  email: details.email,
  type: details.userType,
  profileId: details.profileId,
  schoolId: details.schoolId,
  campusId: details.campusId,
})

identityFromUserDetails is a field rename — userIdsub, userTypetype. The server mints userDetails at dispatch; the worker echoes this identity on every RPC. It is never derived from model output, so tool schemas must not contain userId, staffId, or "who is calling". Guard: tool-context.spec.ts.

HelperDirectionPage
resolveIdentityworker: parsed context → identityThe RPC hop
toJwtPayloadserver: identity → JwtPayload with synthetic iat/exp, so the ability factory can runThe RPC hop

Call scope

type AgentCallScope = {
  mode: AgentRunModeName  // 'school' | 'campus'
  campusId?: string
}

Minted server-side alongside identity. In campus mode campusId is the campus the staffer is working in, and handlers taking an optional campusId argument prefer the envelope over the model:

const pinned = payload?.scope?.campusId
campusId: pinned ?? requested

Skip that and a campus-mode caller can pass another campus's id in args, which the service will honour. School mode leaves campusId undefined — there the model must name a campus explicitly. The worker builds this from runMode in resolveCallScope.

Two different fields called scope

AgentCallScope is which campus this call is pinned to. Tool scope (general / campus / school) is which run modes may be offered the tool — see Defining a tool.

Request and result

Every RPC payload is an AgentToolRequest; every RPC return is an AgentToolResult. The model sees one shape for every tool, local or RPC.

type AgentToolRequest<A> = {
  identity: AgentIdentity
  scope: AgentCallScope
  args: A   // the model's tool arguments, pre-parse
}

type AgentToolResult<T = unknown> =
  | { data: T; scopeContext: string }
  | { error: string }
FieldRule
argsUnparsed JSON. The server safeParses it against the same Zod schema the definition carries. Do not parse in the handler yourself.
scopeContextA sentence the model is prompted to read, so it does not describe campus-filtered data as school-wide. Produced by describeScopeContext.
errorNever throw to RabbitMQ. A denied permission is { error: 'Permission denied…' }, not HTTP 403.

Not a gate

Two fields in this contract look like authorization and are not.

FieldWhy it is advisory
userPermissions{ action, resource, condition? } — one flattened CASL rule, so Rover stops offering to change a record it cannot change. But it is a dispatch-time snapshot (stale after a mid-conversation role change), local tools that read it have no tenant scoping, and anything in the model's context can be argued with by a crafted message.
runModeA client lying about school vs campus only changes what Rover offers; results are still CASL-filtered. The server validates mode anyway, because a wrong mode makes Rover confidently misdescribe its own answers.

Every RPC call is re-authorized on the server against a freshly rebuilt ability. That remains the only gate.

Where to go next

On this page