Adding a local tool
A worker-only tool that touches no school data — one file, the shared envelope, no Nest DI.
You need the agent to do something that is not a school record: read the clock, ask the staffer a question, inspect the in-process agent map, save a file into this turn's sandbox. Local tools run inside the worker. There is no RabbitMQ hop and no CASL gate — because there is nothing to authorize.
If you are about to call a Nest service or a repository, stop. That is an RPC tool.
What you will touch
- A new file under
apps/worker/src/naalya-agent/core/tools/local/. local/index.ts— add it toLOCAL_AGENT_TOOLS(or export a factory if it needs the sandbox).- Education Hub
src/components/rover/activity/tool-labels.tsif the Hub should narrate it. - A spec next to
local-tools.spec.ts.
You do not touch RPC_AGENT_TOOLS, AgentToolsController, or defineAgentTool. The worker helpers that consume this registry are buildAgentTools (spreads LOCAL_AGENT_TOOLS onto Rover) and buildRoleTools (mounts createAskUserTool(role) onto each specialist — not the whole local array).
| Helper | What it is |
|---|---|
LOCAL_AGENT_TOOLS | The always-on supervisor array: get_current_date, get_agent_map, ask_user. Spread by buildAgentTools. |
createAskUserTool(role) | Same ask_user tool, stamped with the role so the interrupt UI attributes the question to Angie / Bean / Sarah. Mounted by buildRoleTools. |
createSaveArtifactTool / createLoadArtifactTool / createListThreadFilesTool / createViewImageTool / createGenerateImageTool | Factories. Not in LOCAL_AGENT_TOOLS. They need this turn's sandbox / thread-files / OpenAI client. Passed in as backendTools from the graph builder. |
Step 1: Write the tool
Use LangChain's tool() helper. Keep schema and execute in the same file — nothing about a local tool is shared with the server.
Return the same envelope as RPC tools, JSON-stringified, so the model reads one shape everywhere:
const currentDateTool = tool(
() => {
const now = new Date();
const formatted = new Intl.DateTimeFormat('en-GB', {
timeZone: SCHOOL_TIME_ZONE,
dateStyle: 'full',
timeStyle: 'short',
}).format(now);
return JSON.stringify({
data: {
localTime: formatted,
timeZone: SCHOOL_TIME_ZONE,
iso: now.toISOString(),
},
scopeContext: "The school's local date and time.",
});
},
{
name: 'get_current_date',
description:
"Get today's date and the current time in the school's time zone. Use it before answering anything relative — deadlines, whether a term has started, what 'next week' means — rather than assuming.",
schema: z.object({}),
},
);That file is the whole implementation: no event string, no posture, no audit.
Schema
Same JSON-Schema rule as RPC: no z.date(). Empty object is fine. If you take arguments, they still must not include identity — even locally, do not let the model choose "who is asking." Read identity from ToolRuntime if you truly need it (see ask_user), the same way RPC proxies do.
Step 2: Register it
Always-on supervisor tools go in the array buildAgentTools already spreads:
const LOCAL_AGENT_TOOLS = [
currentDateTool,
agentMapTool,
askUserTool,
] as const;ask_user is also mounted on role subagents via createAskUserTool(role), so Bean/Angie/Sarah can pause the group chat themselves. If your tool should exist on a subagent, follow that factory pattern in buildRoleTools — do not silently dump every local tool onto every graph.
Sandbox-bound tools stay out of LOCAL_AGENT_TOOLS. They need this turn's backend (save_artifact, load_artifact, list_thread_files, view_image, generate_image). Export a factory and pass the result in as backendTools from the graph builder. They only exist on turns that actually have those mounts.
Step 3: No Nest DI
These files are loaded by LangGraph Studio, which has no Nest container. A tool that needs config or an HTTP client should export a factory:
export const createGenerateImageTool = (openai: GenerateImageOpenAI) =>
tool(async (args) => { /* use openai, not ConfigService */ }, { name: 'generate_image', /* ... */ });Reach for ConfigService or @Inject() and Studio (and the unit tests) break.
Step 4: Label and test
Hub label — same map as RPC, keyed by name:
get_current_date: {
running: 'Checking the date…',
done: 'Read the date',
icon: Calendar03Icon,
},Worker spec — assert it is a real LangChain tool (has invoke) and that the parsed payload has data + scopeContext:
it('are ordinary LangChain tools, so they need no RPC plumbing', () => {
for (const localTool of LOCAL_AGENT_TOOLS) {
expect(typeof localTool.name).toBe('string');
expect(typeof localTool.invoke).toBe('function');
}
});Gotchas
Do not call a repository. The moment you need StaffService, you needed RPC. There is no tenant frame here.
Keep the envelope. Returning a bare string or a thrown error teaches the model a second contract and makes traces harder to read.
LOCAL_AGENT_TOOLS is mostly supervisor-facing. Spreading a new tool into that array does not automatically give it to Angie. Role graphs get createAskUserTool(role) plus their RPC set. If the specialist needs your local tool, wire it in buildRoleTools on purpose.
Permissions snapshot in context is not a gate. Local tools that read userPermissions from runtime context (the agent map does this for display) must never decide authorization from it. It is a stale advisory snapshot. See the comment on agentPermissionSchema.