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
| Part | What it covers | Where |
|---|---|---|
| The envelope | What the server mints per turn and what crosses the wire: runtime context, identity, call scope, request/result. | This page |
| The definition | defineAgentTool and its eight fields — how one tool is declared. | Defining a tool |
| The registry | RPC_AGENT_TOOLS, the types derived from it, and who is offered which tool. | The tool registry |
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 AgentToolCallerRuntime 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.
| Field | What it is |
|---|---|
userDetails | Who is chatting — name, email, user id, profile id, user type, school, optional campus. Parsed by agentUserDetailsSchema. |
runMode | school or { mode: 'campus', campus: { id, name } }. A campus mode with no campus is a type error (agentRunModeSchema is a discriminated union). |
currentTime | z.coerce.date() because JSON turns a Date into a string across RMQ. The default is a factory, not new Date() at module load. |
timeZone | IANA name from school config (Africa/Kampala). Optional — omit rather than guess. |
userPermissions | Flattened CASL snapshot. Advisory only — see Not a gate. |
capabilities | { isClassTeacher, isSubjectTeacher, isDirectorOfStudies }. Defaults all false so old payloads still parse. |
currentAcademicYear | Name, year, optional campusAcademicYearId. Omitted when no current year is configured. |
terms | Current year's terms, ordered by termNumber. Empty when there is no pinned campus. |
teachingMap | Class/subject assignments. Omitted for non-teachers — presence itself is a capability signal. |
attachments | Files 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
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 — userId → sub, userType → type. 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.
| Helper | Direction | Page |
|---|---|---|
resolveIdentity | worker: parsed context → identity | The RPC hop |
toJwtPayload | server: identity → JwtPayload with synthetic iat/exp, so the ability factory can run | The 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 ?? requestedSkip 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 }| Field | Rule |
|---|---|
args | Unparsed JSON. The server safeParses it against the same Zod schema the definition carries. Do not parse in the handler yourself. |
scopeContext | A sentence the model is prompted to read, so it does not describe campus-filtered data as school-wide. Produced by describeScopeContext. |
error | Never 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.
| Field | Why 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. |
runMode | A 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.