Naalya Handbook
AI System

Defining a tool

defineAgentTool and its eight fields — naming and verbs, run-mode visibility, which graph gets the tool, and what may never be in the schema.

Every RPC tool is one defineAgentTool call. You pass one object; it returns that object unchanged. There is no runtime behaviour — the function exists so TypeScript keeps the literal event string and the concrete Zod schema instead of widening them to string and ZodObject.

libs/shared/src/naalya-ai/tools/define-agent-tool.ts
const defineAgentTool = <E extends DottedEvent, S extends ZodObject>(
  definition: AgentToolDefinition<E, S>,
): AgentToolDefinition<E, S> => definition

Those preserved literals are what make AgentToolEvent and AgentToolPayload work (see The tool registry). Write a plain object instead and RPC_AGENT_TOOLS collapses to the widest types — AgentToolPayload['create.department'] would no longer know that tool's args.

The definition is deliberately free of @langchain/*. The server imports the schema; the worker wraps the same object in a generic proxy. One object, two processes.

The eight fields

type AgentToolDefinition<E extends DottedEvent, S extends ZodObject> = {
  event: E
  scope: AgentToolScope
  roles?: AgentToolRole[]
  shared?: boolean
  name: string
  description: string
  schema: S
  streamOnSuccess?: (args: unknown) => Record<string, unknown> | undefined
}
FieldRequiredWhat it is
eventyesRabbitMQ message pattern, verb.subject. See Naming.
scopeyesWhich run modes may see the tool. See Visibility.
rolesnoWhich graphs receive it. Omit → Rover. See Placement.
sharednoGive it to Rover and every subagent. See Placement.
nameyesSnake_case name the model emits, and the Education Hub TOOL_LABELS key (src/components/rover/activity/tool-labels.ts). Not the RabbitMQ pattern.
descriptionyesPlain English the model reads to decide whether to call this tool — what it does, what it returns, and any envelope rules ("when you are already in a campus, omit campusId"). Not a code comment, not Hub copy. Copy a neighbour's description and Rover calls the wrong tool. The worker strips em dashes with withoutEmDash first.
schemayesZod object of LLM-visible arguments. See Schema rules.
streamOnSuccessnoAfter the RPC returns without { error }, toRpcProxy may runtime.writer?.(...) with whatever this returns. Return undefined to emit nothing. Most tools omit it.

roles and shared decide who is offered the tool — they are not authorization. That is the handler's auth posture on the hop.

Naming

event and name are two different strings and are allowed to differ. Keep the pair consistent:

FieldShapeUsed by
eventdotted create.department (type `${string}.${string}`)The worker proxy sends it; the controller listens with @MessagePattern(createDepartmentTool.event). Always reference the constant — never retype the string.
namesnake_case create_departmentWhat the model emits, and the Hub activity-label key.

Mixing them (@MessagePattern('create_department'), or a label keyed create.department) silently breaks the hop or the timeline. Underscores belong inside the subject (campus_staff), never as a second dot.

Verbs decide auditing

The type only checks that there is a dot. Which verbs are allowed is tools/mutating-verb.ts, which classifies the prefix so three things cannot drift: the write-audit guard knows which tools must prove they audit exactly once on success; the agent-map endpoint can label a tool mutating without a second hand-kept list; and an unknown verb fails loudly instead of defaulting to "not mutating, therefore no audit".

const isMutatingEvent = (event: string): boolean => MUTATING_VERB.test(event)
KindPrefixes (today)
Read — must not auditlist, get, search, react (KNOWN_READ_VERBS)
Mutating — must auditcreate, update, delete, upsert, publish, unpublish, duplicate, recompute, add, remove, close, assign, unassign, reorder, save, finalise, submit, approve, return, recall, suspend, unsuspend, admit, reject, archive, unarchive, lock, unlock, invite, respond, leave (KNOWN_MUTATING_VERBS)

A prefix in neither list fails agent-tools-write-audit.spec.ts until you classify it. That is intentional.

Visibility

scope is run-mode visibility, not campus-id filtering. Permissions flow down, never up — school mode reaches every tool scope, campus mode never sees scope: 'school'.

const TOOL_SCOPES_BY_MODE: Record<AgentRunModeName, ReadonlyArray<AgentToolScope>> = {
  school: ['general', 'campus', 'school'],
  campus: ['general', 'campus'],
}
scopeCampus modeSchool modeChoose it when
generalyesyesOne record by id, or anything whose CASL conditions already narrow it (get_staff, get_department).
campusyesyesMeaningful inside a campus. School-wide callers may still use it, often with an optional campusId ignored when the envelope pins one.
schoolnoyesThe name implies the whole school (list_staff). A campus user must not be offered it, or they would get campus-filtered rows described as school-wide.

scope: 'campus' does not by itself pin campusId on the query — pinning is the handler reading payload.scope.campusId. The field only controls whether the tool appears in the model's menu.

Placement

roles and shared control which compiled graph receives the tool. The worker never keeps a hand-written "Angie's tools" list; toolsForRole filters the registry.

type AgentToolRole = 'class_teacher' | 'subject_teacher' | 'dos'
roles valueWho gets the toolAlso on Rover?
omitted or []Rover onlyyes
['class_teacher']Angieno — he delegates with task
['subject_teacher']Beanno
['dos']Sarahno
['class_teacher', 'dos']Angie and Sarahno
shared: trueRover and every role subagentyes

Listing more than one role is not a partition — get_grading_config is on both teaching graphs, and list_stream_roster is class-teacher and Sarah, so the DOS borrows read tools without a new server surface. The handler's posture still has to admit her.

shared is the escape hatch for context any agent may need while doing something else — school branding before writing a PDF. Two rules: leave roles unset (the supervisor filter in buildAgentTools drops anything with a non-empty roles array, so an omitted roles is what keeps a shared tool on Rover), and never combine shared: true with a roles list.

libs/shared/src/naalya-ai/tools/school-branding.tool.ts
scope: 'campus',
shared: true,
name: 'get_school_branding',

Deciding placement

roles, shared, and scope are the whole placement surface — there is no second registry.

  1. Who should perform this, not who might mention it? If only a subject teacher should create the row, it is Bean's tool. Rover delegates via task.
  2. Would a campus user misuse a school-wide name? If yes, split into a school tool and a campus tool, like staff list.
  3. Does every agent need it as context while doing something else? Branding — shared: true, no roles.
  4. Does the skill you just wrote name this tool? The persona owning the skill must own the tool. If Bean's playbook calls list_templates, it must be on Bean's graph.

A role tag is not authorization

roles: ['class_teacher'] does not prove the caller is a class teacher — it only decides which graph offers the tool. The handler still runs casl or relationship, and a capability that was false at dispatch means the subagent was never registered, so Rover cannot delegate to it at all.

Drift guard: every RPC_AGENT_TOOLS entry must appear under at least one agent (agent-map.spec.ts). A tool with roles: ['class_teacher'] and scope: 'school' still satisfies the guard in school mode while campus-only teachers never see it — think through both axes.

Schema rules

A Zod object. The worker sends it to the model as JSON Schema; the server safeParses payload.args against the same schema inside execute(). .describe('…') on a field is also for the model.

AllowedArguments the model may choose: names, search strings, record ids of the thing being acted on, optional filters.
Forbidden — identityuserId, staffId, jwt, "who is calling". Identity comes from payload.identity. Guard: tool-context.spec.ts.
Forbidden — datesz.date() / z.coerce.date(). LangChain converts every tool schema at bind time and one date field fails the whole turn. Use ISO strings. Guard: tool-json-schema.spec.ts.
  • Campus-like fields (campusId) are allowed only with the envelope-preference pattern in the handler. Document that in description.
  • Empty is valid: z.object({})list_departments, get_school_branding, list_my_classrooms.
  • Year-scoped services still need campusAcademicYearId in the schema (or resolved from context). RPC restores the tenant frame, not the academic year, so a bare service.list({}) from a tool returns cross-year data. See Request Scoping.

Worked example

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(),
    permissions: z
      .array(z.object({ action: z.enum(Action), resource: z.enum(Resource) }))
      .optional(),
  }),
})

No roles → Rover. scope: 'campus' → offered in both modes. The handler still has to authorize the create and pin campusId.

Export the constant, put it in the domain as const array (departmentTools), and spread that array into RPC_AGENT_TOOLS if the domain file is new. defineAgentTool itself registers no RabbitMQ handler, checks no CASL, audits nothing, and does not appear in the Hub — those are the hop and the assembly steps.

Where to go next

On this page