Naalya Handbook
AI System

The RPC hop

Every helper from model.invoke to the domain service — worker proxy, RabbitMQ call, execute(), tenant scope, both auth postures, and audit.

You declared the tool in Defining a tool. This page is one call, in the order it actually runs. Each helper takes the output of the one above it. Skip execute() and you skip parse, tenant fail-closed, ability rebuild, the permission gate, and audit — HTTP guards do not run on RabbitMQ.

one invoke
model.invoke(name, args)
  → toRpcProxy
      → resolveIdentity + resolveCallScope
      → AgentToolService.call(event, { identity, scope, args })
          → @MessagePattern(event)
              → execute(payload, schema, posture, fn)
                  → inTenantScope + ability rebuild
                  → runInToolScope  |  runInRelationshipToolScope
                  → auditToolWrite (writes only)
              ← { data, scopeContext } | { error }
          ← same envelope
      ← JSON.stringify(envelope)

Worker: reading the context

File: apps/worker/src/naalya-agent/core/tools/tool-context.ts.

The proxy must not trust model arguments for "who is calling" or "which campus". It reads the runtime context the server minted at dispatch. These four helpers are that read.

resolveRuntimeContext

const resolveRuntimeContext = (runtime: {
  context?: unknown
}): AgentRuntimeContext | null => {
  const parsed = agentRuntimeContextSchema.safeParse(runtime?.context)
  return parsed.success ? parsed.data : null
}

Parses runtime.context with agentRuntimeContextSchema. Returns null on a malformed dispatch instead of failing deep inside a server handler. Every other helper on this file goes through this first.

resolveIdentity

const resolveIdentity = (runtime: { context?: unknown }): AgentIdentity | null => {
  const context = resolveRuntimeContext(runtime)
  return context ? identityFromUserDetails(context.userDetails) : null
}

Identity comes only from that parsed context. identityFromUserDetails is the shared rename documented on the contract page. null means the call cannot run securely.

resolveCallScope

const resolveCallScope = (runtime: { context?: unknown }): AgentCallScope | null => {
  const context = resolveRuntimeContext(runtime)
  if (!context) return null
  const { runMode } = context
  return runMode.mode === 'campus'
    ? { mode: 'campus', campusId: runMode.campus.id }
    : { mode: 'school' }
}

Campus mode pins campusId here. That is why campus-scoped handlers read it from the envelope and ignore a model-supplied id. School mode leaves campusId off the object.

resolvePermissions

const resolvePermissions = (runtime: { context?: unknown }) =>
  resolveRuntimeContext(runtime)?.userPermissions ?? []

The caller's flattened CASL snapshot, for shaping what Rover offers — never for deciding whether a tool may run. A local tool gating on this would bypass tenant scoping too. get_agent_map reads it for display. RPC tools must not.

CONTEXT_MISSING_ERROR

const CONTEXT_MISSING_ERROR =
  'User context is unavailable for this tool call, so it cannot run securely. Do not retry.'

Returned (stringified as { error }) when identity or call scope is null. The "Do not retry" phrasing matters: agents self-retry error results, which would turn a missing context into a loop.


Worker: wrapping the definition

File: apps/worker/src/naalya-agent/core/tools/build-agent-tools.ts.

You do not write a proxy per tool. One generic body wraps every RPC_AGENT_TOOLS entry.

withoutEmDash

apps/worker/src/naalya-agent/core/without-em-dash.ts
const withoutEmDash = (text: string): string =>
  text.replaceAll(` ${EM_DASH} `, ', ').replaceAll(EM_DASH, ', ')

Applied to definition.description before the model sees it. Agent-facing copy must not teach the character. You still write the description in shared without relying on this as a style guide — the helper is a last pass.

toRpcProxy

apps/worker/src/naalya-agent/core/tools/build-agent-tools.ts
const toRpcProxy = (definition: AnyAgentTool, toolCaller: AgentToolCaller) =>
  tool(
    async (args, runtime: ToolRuntime) => {
      const identity = resolveIdentity(runtime)
      const scope = resolveCallScope(runtime)
      if (!identity || !scope) {
        return JSON.stringify({ error: CONTEXT_MISSING_ERROR })
      }

      const result = await toolCaller.call(definition.event, {
        identity,
        scope,
        args,
      } as never)

      if (!('error' in result)) {
        const event = definition.streamOnSuccess?.(args)
        if (event) runtime.writer?.(event)
      }

      return JSON.stringify(result)
    },
    {
      name: definition.name,
      description: withoutEmDash(definition.description),
      schema: definition.schema,
    },
  )

Three jobs, always:

  1. Take identity and call scope from runtime context.
  2. RPC to the server with definition.event (the RabbitMQ pattern, not name).
  3. Stringify { data, scopeContext } or { error } back to the model.

On success, optionally emit streamOnSuccess. On { error }, skip that so the Hub does not celebrate a failed write.

Per-tool copies of this function only created places for the three jobs to drift. Do not fork it.


Worker: sending the call

AgentToolService.call

AgentToolService is the production AgentToolCaller. File: apps/worker/src/naalya-agent/core/tools/agent-tool.service.ts.

async call<E extends AgentToolEvent>(
  event: E,
  payload: AgentToolPayload[E],
): Promise<AgentToolResult>

What it actually does:

StepHelper / constantWhy
SendtoolProxy.send(event, payload)Nest RMQ client on PROXY_CLIENTS.AGENT_TOOL.
Bound waittimeout(TOOL_RPC_TIMEOUT_MS)10 secondsAn unbounded wait hangs the turn.
Log size, not contentdescribeArgs (argument keys only), sizeOf (byte length of the result)A tool result lands in the model's context window. Logging values would leak school data.
Semantic { error }logger.warnEasy to miss when it was debug-only.
Transport failuredescribeCaughtNest often rejects with a bare string ("There is no matching message handler…"), not an Error. (error as Error).message would be undefined.
Map transport to { error }timeout vs otherAgents self-retry error results. A timed-out call may still be processing server-side; retrying amplifies the outage.

Transport error strings tell the model this is infrastructure, not a bad argument, and not to retry:

The "create.department" tool timed out, the platform may be slow and could still be processing the request. This is an infrastructure issue… Do not retry this call…

Semantic errors (permission denied, invalid args, not-found) come back as { error } result objects from the server with full detail. Those never enter the catch. The model can recover from those.


Worker: putting the tool on a graph

createRoverGraph runs per turn. Tools are not cached across messages.

buildAgentTools

const buildAgentTools = (
  toolCaller: AgentToolCaller,
  mode: AgentRunModeName = 'school',
) => [
  ...toolsForMode(mode)
    .filter((definition) => !definition.roles?.length)
    .map((definition) => toRpcProxy(definition, toolCaller)),
  ...LOCAL_AGENT_TOOLS,
]

Rover's belt for this run:

  1. RPC tools allowed in this run mode, minus anything with a non-empty roles array (those moved to a specialist).
  2. shared: true tools stay — they have no roles.
  3. Plus LOCAL_AGENT_TOOLS (clock, agent map, ask_user).

buildRoleTools

const buildRoleTools = (
  role: AgentToolRole,
  toolCaller: AgentToolCaller,
  mode: AgentRunModeName = 'school',
) => [
  ...toolsForRole(role, mode).map((definition) =>
    toRpcProxy(definition, toolCaller),
  ),
  createAskUserTool(role),
]

One specialist's belt: that role's RPC tools (including shared) for the run mode, plus ask_user stamped with the role so the interrupt UI attributes the question to Angie / Bean / Sarah.

Thin wrappers, same helper:

WrapperCalls
buildClassTeacherToolsbuildRoleTools('class_teacher', …)
buildSubjectTeacherToolsbuildRoleTools('subject_teacher', …)
buildDosToolsbuildRoleTools('dos', …)

There is no buildAngieTools array to edit. Tag roles on the definition.

selectSubagents

File: apps/worker/src/naalya-agent/core/subagents/index.ts.

const SUBAGENT_FACTORIES: Record<AgentToolRole, SubagentFactory> = {
  class_teacher: classTeacherSubagent,
  subject_teacher: subjectTeacherSubagent,
  dos: dosSubagent,
}

const selectSubagents = (
  context: AgentRuntimeContext,
  toolCaller: AgentToolCaller,
  deps: SubagentDeps,
): DelegateSubAgent[] =>
  eligibleAgentRoles(context.capabilities).map((role) =>
    SUBAGENT_FACTORIES[role](context, toolCaller, deps.buildBackend(role)),
  )

eligibleAgentRoles (shared, agent map) decides whether a role registers this turn. SUBAGENT_FACTORIES decides what it builds. Keyed by the same AgentToolRole union — a new role cannot be added to one without the type checker demanding the other.

Registration order follows eligibleAgentRoles, which is also what the registry endpoint returns. A caller can hold several hats: teaching assignments plus DOS authority.

Capabilities are resolved server-side. The model cannot add a specialist to the roster.


Server: the handler

AgentToolsController listens with @MessagePattern(tool.event). Every handler must call this.execute(...). If you call a service directly from the handler, you skip the rest of this page.

@MessagePattern(createDepartmentTool.event)
createDepartment(
  @Payload() payload: AgentToolPayload[typeof createDepartmentTool.event],
) { … }

The payload is the AgentToolRequest from the contract page: identity, scope, args. Type it from AgentToolPayload[typeof thatTool.event].


Server: execute()

private async execute<A, T>(
  payload: AgentToolRequest<A> | undefined,
  schema: ZodType<A>,
  posture: ToolPosture<T>,
  fn: (args: A, jwt: JwtPayload) => Promise<T>,
): Promise<AgentToolResult<T>>

Four arguments, always:

ArgumentWhat you passWhy
payloadthe @Payload()identity, call scope, raw args
schemacreateDepartmentTool.schemasame Zod object as defineAgentTool
posture{ auth: 'casl', gate } or { auth: 'relationship' }how this call is authorized, and optional audit
fn(args, jwt) => this.someService.method(...)the actual work, after the gates

fn receives parsed args and a JwtPayload rebuilt from payload.identity. Use jwt, never args, for "who is doing this."

What runs, in order

  1. Missing identity.sub or identity.type{ error: 'Missing tool-call identity.' }. Service not called.
  2. schema.safeParse(args) fails → { error: 'Invalid tool arguments: …' }. Service not called.
  3. hasNoTenantScope{ error: NO_TENANT_SCOPE_ERROR }. Fail closed.
  4. Rebuild a fresh CASL ability inside inTenantScope (platform admin → runUnscoped, else withSchool). No HTTP middleware ran on this path, so this is the only ability that exists.
  5. Branch on posture.auth: runInToolScope or runInRelationshipToolScope.
  6. If the result is { data } and posture.audit is set → auditToolWrite emits exactly one row.
  7. Any throw → { error: … } via toToolError.

Server: tenant and ability

File: apps/server/src/app/naalya-ai/tools/tool-scope.ts.

toJwtPayload

private toJwtPayload(identity: AgentIdentity): JwtPayload {
  return { ...identity, iat: 0, exp: 0 }
}

iat / exp are synthetic zeros. Ability resolution only reads sub / type / schoolId / campusId.

hasNoTenantScope and NO_TENANT_SCOPE_ERROR

export const NO_TENANT_SCOPE_ERROR = 'No school context for this user.'

export const hasNoTenantScope = (jwt: JwtPayload) =>
  jwt.type !== UserType.PLATFORM_ADMIN && !jwt.schoolId

A non-platform user with no schoolId cannot be pinned to a tenant. Both execute and the two posture runners check this. Fail closed.

inTenantScope

export const inTenantScope = <T>(
  jwt: JwtPayload,
  fn: () => Promise<T>,
): Promise<T> =>
  jwt.type === UserType.PLATFORM_ADMIN
    ? runUnscoped(fn)
    : withSchool(jwt.schoolId as string, fn)

The tenant frame a tool call runs in — the single definition, shared by the CASL rebuild and the service call. Both need it: the ability factory reads tenant-scoped tables (UserXRoleEntity), and on the RPC path no middleware has established a context first. Guard with hasNoTenantScope before calling.

Platform admins run unscoped (cross-tenant), mirroring their REST access. Other staff run pinned to jwt.schoolId.

runWithAmbientAbility

const runWithAmbientAbility = <T>(
  jwt: JwtPayload,
  ability: AppAbility,
  fn: () => Promise<T>,
): Promise<T> =>
  inTenantScope(jwt, () => permissionScopeStore.run({ ability }, fn))

Puts ability in permissionScopeStore and pins the tenant. Shared by both postures.

Why relationship tools still get an ability in the store even though they do not gate on it: some services have a CASL fallback for admins (if (!ability) return — a carve-out for trusted jobs). The RPC seam is an untrusted caller. If the store were empty, that fallback would fail open. Populating the store is not a second gate; it closes that hole. A subject teacher briefly received a whole-class gradebook through that path before this existed.


Server: the two postures

posture is a required discriminated union. auth is not optional.

apps/server/src/app/naalya-ai/agent-tools.controller.ts
type ToolPosture<T> =
  | {
      auth: 'casl'
      gate: { action: Action; resource: Resource }
      audit?: ToolAuditDescriptor<T>
    }
  | {
      auth: 'relationship'
      scopeNote?: string
      audit?: ToolAuditDescriptor<T>
    }

That is the whole legal surface. There is no third auth value.

What both postures share

  • Tenant frame (withSchool / runUnscoped).
  • A freshly rebuilt ability placed in permissionScopeStore.
  • Never throw to RabbitMQ — always { data, scopeContext } or { error }.
  • Optional audit on success.

Where they differ

auth: 'casl'auth: 'relationship'
Question it answersDoes this user's role grant action on resource?Does this user personally own (or otherwise self-assert access to) this data?
Gateability.can(gate.action, gate.resource) before fn. Denied → { error: 'Permission denied…' }, fn never runs.No ability.can. fn runs; the service (or the handler body) throws ForbiddenException / NotFoundException if the relationship does not hold.
Required extra fieldsgate: { action, resource }none. gate is not a legal key.
scopeContextDerived from the CASL rules for that gate (describeScopeContext).A fixed string: scopeNote, or DEFAULT_RELATIONSHIP_SCOPE_NOTE.
When to useThe resource is in the Resource enum and seeded roles already grant it. Same pair as the HTTP @RequirePermissions on the twin route.Teacher-tier work (class/subject assignment), or a call that is "this user's own school" with no CASL resource to gate on (branding).
ExecutorrunInToolScoperunInRelationshipToolScope

runInToolScope

export const runInToolScope = async <T>(
  ctx: { jwt: JwtPayload; ability: AppAbility },
  gate: { action: Action; resource: Resource },
  fn: () => Promise<T>,
): Promise<ToolScopeResult<T>>

CASL-gated tools:

  1. ability.can(gate.action, gate.resource) — denied returns immediately, fn never runs.
  2. hasNoTenantScope — fail closed.
  3. runWithAmbientAbility{ data: await fn(), scopeContext: describeScopeContext(...) }.

Use when the HTTP route for the same operation is @RequirePermissions(Action.X, Resource.Y). Copy that pair into gate. If you invent a different pair, the agent and the REST API disagree about who may do the work.

deleteDepartment is an example of not using CASL even though a Resource.DEPARTMENT exists: the HTTP remove path asserts department-head membership or DELETE:DEPARTMENT. That is a relationship (plus a permission fallback inside the service), so the tool uses auth: 'relationship' with audit. Match the service's real rule, not the resource name.

describeScopeContext

Private to tool-scope.ts. Only runInToolScope calls it. Turns CASL rules into a sentence the model can qualify its language with:

CASL rules for that gateSentence
Any unconditioned can"These results include all records across the entire school."
Conditions on guestId / studentProfileId / userId"…limited to the current user's own records only…"
Conditions on campusId or id"…scoped to the user's assigned campus(es) only…"
Anything else"…may be filtered based on the user's access level."

Relationship tools do not go through this. Their sentence is a constant (next).


runInRelationshipToolScope

export const runInRelationshipToolScope = async <T>(
  jwt: JwtPayload,
  ability: AppAbility,
  scopeNote: string,
  fn: () => Promise<T>,
): Promise<ToolScopeResult<T>>
  1. hasNoTenantScope — fail closed.
  2. runWithAmbientAbility{ data: await fn(), scopeContext: scopeNote }.
  3. catch{ error: toToolError(e) }. Never throws.

The classroom/grading/lesson services already resolve the staff profile and assert ownership. That relationship is the gate. Passing jwt.sub into the service is the point — the model cannot choose whose classrooms to list.

scopeNote is the caller's responsibility. The teaching-specific default lives on the controller, not in tool-scope.ts, because this module is posture-neutral plumbing.

Some relationship handlers assert in the handler body before reading (ClassroomAccessService.assertClassStudentReadAccess), then load data. The posture still has to be relationship; putting casl + STUDENT:READ would let any staff with that permission through even if they are not the class teacher.

scopeNote constants

On AgentToolsController. execute uses posture.scopeNote ?? DEFAULT_RELATIONSHIP_SCOPE_NOTE.

ConstantSentenceWhen
DEFAULT_RELATIONSHIP_SCOPE_NOTELimited to the classes and subjects the signed-in teacher is assigned to teach.Teaching stream / gradebook tools. Omit scopeNote.
CLASS_STUDENT_SCOPE_NOTELimited to students in classes where the signed-in teacher is the class teacher.Class-teacher student reads — gated on the student, not subject-teaching.
REPORT_SCOPE_NOTEReports for classes where the staffer is class teacher, or campus-wide with the right permission.Report-card pipeline reads shared by class teacher and DOS.
REPORT_CARD_CONFIG_SCOPE_NOTEVisible to any class teacher, or campus-wide with the right permission.Config is a campus-year + level setting, not owned by one class.
BRANDING_SCOPE_NOTEThis is the signed-in user's own school branding.Stops Rover apologising for a limit that does not exist.

Override when the default sentence would be a lie. Relationship is also used when there is nothing to gate beyond tenant + "this is their school" (branding). That is still relationship, not a gateless CASL call — gateless CASL does not type-check.


auth × audit × (gate | scopeNote). These are all of them.

casl — reads (no audit)

{ auth: 'casl', gate: { action: Action.LIST, resource: Resource.STAFF } }

HTTP @Audit would not fire on a GET anyway. The write-audit guard rejects a casl read that does pass audit.

casl — writes (audit required)

{
  auth: 'casl',
  gate: { action: Action.CREATE, resource: Resource.DEPARTMENT },
  audit: {
    action: ActionAuditAction.CREATE,
    resourceType: AuditResourceType.DEPARTMENT,
    resourceId: (result) => result.id,
  },
}

If isMutatingEvent(tool.event) is true, a fixture must prove execute() emitted exactly one audit row. Missing audit fails the guard.

relationship — reads, default note

{ auth: 'relationship' }

relationship — reads, custom note

{ auth: 'relationship', scopeNote: BRANDING_SCOPE_NOTE }

relationship — writes (audit required)

{
  auth: 'relationship',
  audit: {
    action: ActionAuditAction.DELETE,
    resourceType: AuditResourceType.DEPARTMENT,
    resourceId: (result: string) => result,
  },
}

scopeNote may be added on a write too; it is independent of audit.

Combinations that do not compile

You writeWhy it fails
{ gate: { … } } with no authauth is required
{ auth: 'casl' }gate is required on that branch
{ auth: 'relationship', gate: { … } }gate is not a key on that branch
{ auth: 'casl', scopeNote: '…' }scopeNote is not a key on that branch
{ auth: 'something-else', … }only 'casl' | 'relationship'

TypeScript is the checklist. If a gateless CASL call type-checked, someone would ship it.


Server: errors and audit

toToolError

export const toToolError = (e: unknown, context?: string): string => {
  if (e instanceof HttpException) return e.message
  logger.error({ msg: `Agent tool failed${context ? ` (${context})` : ''}`, err: e })
  return 'That action could not be completed. Do not retry; tell the user what you were trying to do.'
}

Turn a thrown service error into a model-readable, non-retryable string.

  • HttpException messages reach the model (Forbidden, NotFound). Expected rejections, not bugs — not logged as errors.
  • Anything else is logged server-side (context is a label like "create:DEPARTMENT" or "relationship" for the log line; it never reaches the model) and masked.

execute's outer catch uses this too, so a throw from ability rebuild cannot leak a stack to the model.


auditToolWrite

HTTP @Audit() interceptors are HTTP-only. They no-op on @MessagePattern. RPC writes must describe the row themselves.

type ToolAuditDescriptor<T> = {
  action: ActionAuditAction
  resourceType: string
  resourceId?: (result: T) => string
}

export const auditToolWrite = <T>(
  auditLogService: AuditLogService,
  jwt: JwtPayload,
  audit: { action: ActionAuditAction; resourceType: string; resourceId?: string },
  result: { data: T } | { error: string },
): void
FieldMeaning
actionActionAuditAction.CREATE / UPDATE / DELETE / SUSPEND / … — the audit log's verb, not the CASL Action.
resourceTypeUsually AuditResourceType.DEPARTMENT (a string enum).
resourceIdOptional. Called with fn's return value so you can pull result.id. If fn returns the id itself (some deletes), use (result) => result.

auditToolWrite no-ops when the result is { error }. Failed creates are not logged as success. schoolId is taken from the rebuilt JWT because the inTenantScope ALS frame has already closed by the time audit runs — without it the @TenantScoped row persists null and disappears from school-scoped audit views.

Reads must omit audit. Mutating verbs must include it. Classification is the event prefix via isMutatingEvent, not whether you feel like it.


Walking the department create handler

apps/server/src/app/naalya-ai/agent-tools.controller.ts
@MessagePattern(createDepartmentTool.event)
createDepartment(
  @Payload() payload: AgentToolPayload[typeof createDepartmentTool.event],
) {
  const pinned = payload?.scope?.campusId

  return this.execute(
    payload,
    createDepartmentTool.schema,
    {
      auth: 'casl',
      gate: { action: Action.CREATE, resource: Resource.DEPARTMENT },
      audit: {
        action: ActionAuditAction.CREATE,
        resourceType: AuditResourceType.DEPARTMENT,
        resourceId: (result: DepartmentEntity) => result.id,
      },
    },
    ({ campusId: requested, ...dto }, jwt) =>
      this.departmentService.create(
        { ...dto, campusId: pinned ?? requested },
        jwt,
      ),
  )
}

What each line is doing:

  • @MessagePattern(createDepartmentTool.event) — bind this method to 'create.department'. The worker proxy sends that event. A typo here and the call hangs (AgentToolService times out) or hits the wrong handler.
  • AgentToolPayload[typeof createDepartmentTool.event]args is typed from that tool's Zod input.
  • pinned — campus mode: the envelope campus wins. School mode: pinned is undefined, so requested (the model's campusId) is used.
  • auth: 'casl' — creating a department is a seeded role permission, same as POST /departments.
  • gateCREATE × DEPARTMENT. If the caller cannot, fn never runs.
  • audit — mutating verb create. On success, one ACTION row with the new department's id.
  • fn — strip campusId out of args, reattach the pinned-or-requested value, call the same DepartmentService.create the HTTP controller uses, pass jwt as actor.

If this "works" without you knowing those bullets, you copied a shape. The shape is load-bearing.


How to choose a posture

  1. Find the HTTP handler for the same operation.
  2. If it is @RequirePermissions(Action.X, Resource.Y) and the service does not also require "you are the class teacher of this stream", use casl with that gate.
  3. If the service takes jwt.sub (or a staff profile) and asserts assignment / head-of-department / "this is your school", use relationship. Put the same jwt.sub into fn.
  4. If both exist (permission or relationship), look at what the service actually enforces and match that. deleteDepartment is relationship because HEAD membership is sufficient.
  5. Mutating event prefix → add audit. Otherwise omit it.

When in doubt, open an existing handler for the same domain and copy its posture, not just its formatting.


Where to go next

You now have the contract and the hop. What is left is only which files to touch.

On this page