Choosing RPC or local
The one question that decides how you add a tool — does it touch school data?
Every "give the agent X" ticket starts here. Get this wrong and you either ship an unauthorized query (a local tool that quietly hits a repository) or you pay a RabbitMQ round trip for a clock. If none of the machinery below is familiar yet, read How the AI system works first.
The question
Does this tool read or write a school record? Staff, students, departments, grades, templates, knowledge-base documents, anything with a school_id or a CASL resource.
- Yes → RPC. Schema lives in
libs/shared. Implementation lives onAgentToolsController. The worker only has a generic proxy. CASL (or a relationship check), tenant scoping, and audit all run on the server — the only place they exist. - No → local. Schema and implementation live together in
apps/worker/src/naalya-agent/core/tools/local/. No RPC, no Nest injection, no permission gate.
That is the whole rule. "The worker already has the JWT" is not a reason to query the database from a local tool. "It's just a list" is not a reason either. Lists of staff are school data.
Concrete examples
| Tool | Kind | Why |
|---|---|---|
list_staff, create_department, get_scheme_of_work | RPC | Rows in the school database |
get_current_date | Local | A timezone format of new Date() |
get_agent_map | Local | Reads the in-process tool/persona registry, not the DB |
ask_user | Local | Pauses the graph with interrupt() — no server |
save_artifact, load_artifact, view_image | Local (factory) | Bound to this turn's sandbox / thread-files backend |
generate_image | Local (factory) | Calls OpenAI from the worker; no school row |
Sandbox file tools are local even though they feel like I/O. The files are the agent's workspace, not tenant data. They are also not in LOCAL_AGENT_TOOLS — they are factories passed in as backendTools because they cannot be built without the mounts.
Why the seam exists
Authorization for agents is not the HTTP guard on the chat endpoint. The chat endpoint only checks that you are staff. Fine-grained "may this person create a department?" happens inside each RPC handler, against a fresh ability, inside a tenant frame.
A local tool that called StaffService.list() would skip all of that. There is no runInToolScope in the worker. There is no audit interceptor. That is why LOCAL_AGENT_TOOLS's comment is blunt: anything that reads or writes school records belongs in RPC_AGENT_TOOLS.
The reverse mistake is merely expensive: an RPC tool for "what time is it?" would work, after a queue hop, to return a string the worker already knew. Don't.
Checklist before you write
- Name the service method you would call. If it exists on a Nest service and hits Postgres, you are writing an RPC tool.
- If there is no service because the work is "format a date" or "ask the staffer a question", you are writing a local tool.
- If you need config or an HTTP client in a local tool, pass them into a factory. Do not inject Nest providers — LangGraph Studio loads these files without a Nest container.
- Both kinds must return the same envelope:
{ data, scopeContext }or{ error }, JSON-stringified. The model should not have to learn two shapes.
If the answer is RPC, read Defining a tool and The RPC hop first — what you declare, then what runs. Adding an RPC tool is only the file list after those.
Where to go next
Adding an RPC tool
School data: definition, handler, registry, Hub label, guards.
Adding a local tool
No school data: LOCAL_AGENT_TOOLS, factories, createAskUserTool.
Adding a skill
skillSourcesFor, persona folders, and the SKILL.md playbook.
Defining a tool
Background: defineAgentTool and its eight fields.