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.
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
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
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:
- Take identity and call scope from runtime context.
- RPC to the server with
definition.event(the RabbitMQ pattern, notname). - 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:
| Step | Helper / constant | Why |
|---|---|---|
| Send | toolProxy.send(event, payload) | Nest RMQ client on PROXY_CLIENTS.AGENT_TOOL. |
| Bound wait | timeout(TOOL_RPC_TIMEOUT_MS) — 10 seconds | An unbounded wait hangs the turn. |
| Log size, not content | describeArgs (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.warn | Easy to miss when it was debug-only. |
| Transport failure | describeCaught | Nest 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 other | Agents 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:
- RPC tools allowed in this run mode, minus anything with a non-empty
rolesarray (those moved to a specialist). shared: truetools stay — they have noroles.- 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:
| Wrapper | Calls |
|---|---|
buildClassTeacherTools | buildRoleTools('class_teacher', …) |
buildSubjectTeacherTools | buildRoleTools('subject_teacher', …) |
buildDosTools | buildRoleTools('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:
| Argument | What you pass | Why |
|---|---|---|
payload | the @Payload() | identity, call scope, raw args |
schema | createDepartmentTool.schema | same 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
- Missing
identity.suboridentity.type→{ error: 'Missing tool-call identity.' }. Service not called. schema.safeParse(args)fails →{ error: 'Invalid tool arguments: …' }. Service not called.hasNoTenantScope→{ error: NO_TENANT_SCOPE_ERROR }. Fail closed.- Rebuild a fresh CASL ability inside
inTenantScope(platform admin →runUnscoped, elsewithSchool). No HTTP middleware ran on this path, so this is the only ability that exists. - Branch on
posture.auth:runInToolScopeorrunInRelationshipToolScope. - If the result is
{ data }andposture.auditis set →auditToolWriteemits exactly one row. - Any throw →
{ error: … }viatoToolError.
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.schoolIdA 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.
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
abilityplaced inpermissionScopeStore. - Never throw to RabbitMQ — always
{ data, scopeContext }or{ error }. - Optional
auditon success.
Where they differ
auth: 'casl' | auth: 'relationship' | |
|---|---|---|
| Question it answers | Does this user's role grant action on resource? | Does this user personally own (or otherwise self-assert access to) this data? |
| Gate | ability.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 fields | gate: { action, resource } | none. gate is not a legal key. |
scopeContext | Derived from the CASL rules for that gate (describeScopeContext). | A fixed string: scopeNote, or DEFAULT_RELATIONSHIP_SCOPE_NOTE. |
| When to use | The 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). |
| Executor | runInToolScope | runInRelationshipToolScope |
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:
ability.can(gate.action, gate.resource)— denied returns immediately,fnnever runs.hasNoTenantScope— fail closed.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 gate | Sentence |
|---|---|
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>>hasNoTenantScope— fail closed.runWithAmbientAbility→{ data: await fn(), scopeContext: scopeNote }.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.
| Constant | Sentence | When |
|---|---|---|
DEFAULT_RELATIONSHIP_SCOPE_NOTE | Limited to the classes and subjects the signed-in teacher is assigned to teach. | Teaching stream / gradebook tools. Omit scopeNote. |
CLASS_STUDENT_SCOPE_NOTE | Limited 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_NOTE | Reports 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_NOTE | Visible 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_NOTE | This 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.
Every legal combination
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 write | Why it fails |
|---|---|
{ gate: { … } } with no auth | auth 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.
HttpExceptionmessages reach the model (Forbidden, NotFound). Expected rejections, not bugs — not logged as errors.- Anything else is logged server-side (
contextis 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| Field | Meaning |
|---|---|
action | ActionAuditAction.CREATE / UPDATE / DELETE / SUSPEND / … — the audit log's verb, not the CASL Action. |
resourceType | Usually AuditResourceType.DEPARTMENT (a string enum). |
resourceId | Optional. 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
@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 (AgentToolServicetimes out) or hits the wrong handler.AgentToolPayload[typeof createDepartmentTool.event]—argsis typed from that tool's Zod input.pinned— campus mode: the envelope campus wins. School mode:pinnedis undefined, sorequested(the model'scampusId) is used.auth: 'casl'— creating a department is a seeded role permission, same asPOST /departments.gate—CREATE×DEPARTMENT. If the caller cannot,fnnever runs.audit— mutating verbcreate. On success, one ACTION row with the new department's id.fn— stripcampusIdout of args, reattach the pinned-or-requested value, call the sameDepartmentService.createthe HTTP controller uses, passjwtas actor.
If this "works" without you knowing those bullets, you copied a shape. The shape is load-bearing.
How to choose a posture
- Find the HTTP handler for the same operation.
- If it is
@RequirePermissions(Action.X, Resource.Y)and the service does not also require "you are the class teacher of this stream", usecaslwith that gate. - If the service takes
jwt.sub(or a staff profile) and asserts assignment / head-of-department / "this is your school", userelationship. Put the samejwt.subintofn. - If both exist (permission or relationship), look at what the service actually enforces and match that.
deleteDepartmentis relationship because HEAD membership is sufficient. - Mutating
eventprefix → addaudit. 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.