Naalya Handbook
Adding AI Tools

Adding an RPC tool

The files you touch to ship a school-data tool — definition, handler, registry, Hub label, guards.

This page is the assembly order. It does not re-explain the types. If you have not read The shared contract, Defining a tool, and The RPC hop, stop and read those first — copying the snippets below without that will produce something that compiles and that you cannot defend in review.

If the work does not touch school data, this is the wrong page: Adding a local tool.

A tool wraps a service method that already exists. If DepartmentService.create is missing, build the HTTP endpoint first (Add an Endpoint).


Files

  1. libs/shared/src/naalya-ai/tools/<domain>.tool.tsdefineAgentTool + the domain as const array.
  2. libs/shared/src/naalya-ai/tools/index.ts — one ...departmentTools spread, only if this is a new domain file. AgentToolEvent / AgentToolPayload are derived; do not add an enum.
  3. apps/server/src/app/naalya-ai/agent-tools.controller.ts@MessagePattern + this.execute(...). Inject the service; import its module into NaalyaAiModule if it is new there.
  4. Education Hub src/components/rover/activity/tool-labels.ts — key = name.
  5. Guard specs (below).

You do not edit the worker proxy. toRpcProxy is generic. You do not edit toolsForMode, buildAgentTools, or selectSubagents. Tag roles / shared / scope on the definition; the graphs update themselves.


1. Define

libs/shared/src/naalya-ai/tools/department.tool.ts
const createDepartmentTool = defineAgentTool({
  event: 'create.department',
  scope: 'campus',
  name: 'create_department',
  description: 'Create a new department',
  schema: z.object({
    name: z.string().min(1),
    description: z.string().optional(),
    campusId: z.uuid().optional(),
  }),
})

export const departmentTools = [listDepartmentsTool, createDepartmentTool] as const

Every field, and how roles / shared / scope place the tool on a graph: Defining a tool. New verb prefix: classify it in mutating-verb.ts before the audit guard will accept it.


2. Handle

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,
      ),
  )
}

Why casl vs relationship, why audit is here, why pinned ?? requested, and what execute / inTenantScope / runInToolScope actually run: The RPC hop.

Never skip execute(). Never put identity in schema.


3. Label

Key is name (create_department), not event.

src/components/rover/activity/tool-labels.ts
create_department: {
  running: 'Creating a department…',
  done: 'Created a department',
  icon: PlusSignIcon,
},

Unlabelled tools fall back to verb derivation. Mandatory for upsert / publish / recompute. Failed calls are hidden; no error string on this map. See Label an AI Tool Call.


4. Guards

terminal (API repo)
npx vitest run libs/shared/src/naalya-ai/tests/tool-json-schema.spec.ts
npx vitest run libs/shared/src/naalya-ai/tests/tool-scope.spec.ts
npx vitest run apps/worker/src/naalya-agent/core/tools/tests/tool-context.spec.ts
npx vitest run apps/server/src/app/naalya-ai/tests/agent-tools-write-audit.spec.ts
npx vitest run apps/server/src/app/naalya-ai/tests/agent-map.spec.ts
npx tsc --noEmit -p apps/server/tsconfig.app.json

Mutating event prefix: add the write fixture the audit guard asks for. New verb: classify it in mutating-verb.ts first.

Matching skill must not name tools this persona lacks. Campus-scoped handlers pin campusId from the envelope.

The next chat turn picks the tool up. No worker restart beyond the usual rebuild.


Where to go next

On this page