Naalya Handbook
Adding AI Tools

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 on AgentToolsController. 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

ToolKindWhy
list_staff, create_department, get_scheme_of_workRPCRows in the school database
get_current_dateLocalA timezone format of new Date()
get_agent_mapLocalReads the in-process tool/persona registry, not the DB
ask_userLocalPauses the graph with interrupt() — no server
save_artifact, load_artifact, view_imageLocal (factory)Bound to this turn's sandbox / thread-files backend
generate_imageLocal (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

  1. Name the service method you would call. If it exists on a Nest service and hits Postgres, you are writing an RPC tool.
  2. If there is no service because the work is "format a date" or "ask the staffer a question", you are writing a local tool.
  3. 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.
  4. 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

On this page