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.
const defineAgentTool = <E extends DottedEvent, S extends ZodObject>(
definition: AgentToolDefinition<E, S>,
): AgentToolDefinition<E, S> => definitionThose 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
}| Field | Required | What it is |
|---|---|---|
event | yes | RabbitMQ message pattern, verb.subject. See Naming. |
scope | yes | Which run modes may see the tool. See Visibility. |
roles | no | Which graphs receive it. Omit → Rover. See Placement. |
shared | no | Give it to Rover and every subagent. See Placement. |
name | yes | Snake_case name the model emits, and the Education Hub TOOL_LABELS key (src/components/rover/activity/tool-labels.ts). Not the RabbitMQ pattern. |
description | yes | Plain 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. |
schema | yes | Zod object of LLM-visible arguments. See Schema rules. |
streamOnSuccess | no | After 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:
| Field | Shape | Used by |
|---|---|---|
event | dotted 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. |
name | snake_case create_department | What 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)| Kind | Prefixes (today) |
|---|---|
| Read — must not audit | list, get, search, react (KNOWN_READ_VERBS) |
| Mutating — must audit | create, 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'],
}scope | Campus mode | School mode | Choose it when |
|---|---|---|---|
general | yes | yes | One record by id, or anything whose CASL conditions already narrow it (get_staff, get_department). |
campus | yes | yes | Meaningful inside a campus. School-wide callers may still use it, often with an optional campusId ignored when the envelope pins one. |
school | no | yes | The 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 value | Who gets the tool | Also on Rover? |
|---|---|---|
omitted or [] | Rover only | yes |
['class_teacher'] | Angie | no — he delegates with task |
['subject_teacher'] | Bean | no |
['dos'] | Sarah | no |
['class_teacher', 'dos'] | Angie and Sarah | no |
shared: true | Rover and every role subagent | yes |
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.
scope: 'campus',
shared: true,
name: 'get_school_branding',Deciding placement
roles, shared, and scope are the whole placement surface — there is no second registry.
- 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. - Would a campus user misuse a school-wide name? If yes, split into a
schooltool and acampustool, like staff list. - Does every agent need it as context while doing something else? Branding —
shared: true, noroles. - 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.
| Allowed | Arguments the model may choose: names, search strings, record ids of the thing being acted on, optional filters. |
| Forbidden — identity | userId, staffId, jwt, "who is calling". Identity comes from payload.identity. Guard: tool-context.spec.ts. |
| Forbidden — dates | z.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 indescription. - Empty is valid:
z.object({})—list_departments,get_school_branding,list_my_classrooms. - Year-scoped services still need
campusAcademicYearIdin the schema (or resolved from context). RPC restores the tenant frame, not the academic year, so a bareservice.list({})from a tool returns cross-year data. See Request Scoping.
Worked example
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
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.
The tool registry
RPC_AGENT_TOOLS is the only registration — the types derived from it, the mode and role filters, and the agent map the product reads.