Skip to content
tnsaiDocs
GitHub

Tool call hooks

com.tnsai.server.hooks runs a log line or a shell command when an agent calls a tool, when a tool call fails, or when you start and stop an agent. Each hook pairs an event, an optional tool-name matcher and one action. You build a DeclarativeHookRegistry from YAML or from code and attach it to an agent with applyTo.

The hooks are a library. TnsServer does not load a hooks file and does not attach hooks to the agents it creates. Your code decides which agents get hooks.

Example

package com.example.tnsai.docs;

import com.tnsai.agents.Agent;
import com.tnsai.server.hooks.DeclarativeHookRegistry;
import com.tnsai.server.hooks.HookAction;
import com.tnsai.server.hooks.HookConfig;
import com.tnsai.server.hooks.HookEvent;
import com.tnsai.server.hooks.HookMatcher;
import java.io.IOException;
import java.io.InputStream;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.List;
import java.util.Map;

/** Compilation contract for server/hooks.md. */
public final class ServerHooksExample {

    private ServerHooksExample() {
    }

    static DeclarativeHookRegistry fromFile(Agent agent, Path hooksYaml) throws IOException {
        DeclarativeHookRegistry hooks;
        try (InputStream in = Files.newInputStream(hooksYaml)) {
            hooks = DeclarativeHookRegistry.fromYaml(in);    // never throws; bad entries are skipped
        }
        hooks.applyTo(agent);                                // replaces the agent's tool call listener
        return hooks;
    }

    static DeclarativeHookRegistry inCode(Agent agent) {
        DeclarativeHookRegistry hooks = DeclarativeHookRegistry.of(List.of(
            new HookConfig(HookEvent.PRE_TOOL_USE, new HookMatcher("file_*"),
                HookAction.log("${tool} starting")),
            new HookConfig(HookEvent.POST_TOOL_USE, HookMatcher.ALL,
                HookAction.log("${tool} took ${latencyMs} ms, success=${success}")),
            new HookConfig(HookEvent.ON_ERROR, null,
                HookAction.command("echo tool failed >> hooks.log"))));
        hooks.applyTo(agent);                                // fires ON_START once
        return hooks;
    }

    static void stop(DeclarativeHookRegistry hooks, Agent agent) {
        hooks.fireEvent(HookEvent.ON_STOP, Map.of("agent", agent.getId()));   // the registry never fires it
    }

    static DeclarativeHookRegistry recorded(List<String> sink) {
        return DeclarativeHookRegistry.of(
            List.of(new HookConfig(HookEvent.POST_TOOL_USE, HookMatcher.ALL, HookAction.log("${tool}"))),
            (action, context) -> sink.add(action.type() + " " + context));       // your own executor
    }
}

YAML format

fromYaml reads a top-level hooks list:

hooks:
  - event: preToolUse
    matcher: "file_*"
    action:
      type: log
      message: "${tool} starting"
  - event: postToolUse
    matcher: "*"
    action:
      type: log
      message: "${tool} took ${latencyMs} ms, success=${success}"
  - event: onError
    action:
      type: command
      command: "echo tool failed >> hooks.log"

Event names can be camelCase (preToolUse) or upper snake case (PRE_TOOL_USE). Action types are log and command, in any case. A log action needs message, and a command action needs command.

fromYaml does not throw. An entry with a missing or unknown event, action type or field is skipped with a WARN log. If the whole document fails to parse, it logs an ERROR and returns an empty registry. Check getHooks() after loading if a missing hook would matter.

Events

EventFired byContext variables
PRE_TOOL_USEThe agent, before each tool calltool, event (preToolUse)
POST_TOOL_USEThe agent, after each tool call, whether it succeeded or failedtool, event (postToolUse), success, latencyMs
ON_ERRORThe agent, after a failed tool calltool, event, success, latencyMs, error (the exception message)
ON_STARTapplyTo, onceagent (the agent id)
ON_STOPOnly your own fireEvent callWhatever you pass

A tool call that the agent's authorization check denies never reaches the tool, so it fires no hooks.

You can fire any event yourself. fireEvent(event, context) runs every hook for that event and passes your map as the context. fireToolEvent(event, toolName, extraContext) applies the matchers to toolName and adds it to the context as tool.

Matchers

A HookMatcher compares the tool name against a glob. * matches any run of characters, and every other character matches itself. The comparison ignores case. HookMatcher.ALL is *.

Matchers apply to PRE_TOOL_USE and POST_TOOL_USE. A hook with no matcher runs for every tool. On ON_START, ON_STOP and ON_ERROR the matcher is ignored, so an ON_ERROR hook runs for every failing tool.

Actions

A log action writes the message at INFO, prefixed with [HOOK], to the com.tnsai.server.hooks.DeclarativeHookExecutor logger.

A command action runs sh -c <command> in the process working directory. It waits for up to 30 seconds and then kills the command. A non-zero exit is logged at WARN with up to 50 lines of output. A failing action never stops the tool call or the other hooks.

In both actions, ${name} is replaced with the context value of that name. A name that is not in the context stays as written.

Hooks run on the thread that runs the tool, so the tool waits for every PRE_TOOL_USE hook to finish. A slow command delays each matching tool call by up to 30 seconds. A hook cannot veto a call; use tool approval or the approval policies for that.

Substituted values are not quoted. In a command action, ${tool} comes from the model's tool call and ${error} from an exception message, and either can contain shell syntax. Use these variables in log actions. If a command needs them, pass them to a script that treats them as data.

Custom executor

fromYaml(in, executor) and of(hooks, executor) take your own HookExecutor in place of the shell and log executor. The executor receives each matching action and its context map. Use it to record hooks in tests or to send them to your own sink.

Attaching to an agent

applyTo(agent) sets the agent's tool call listener. An agent has one listener, so applyTo replaces any listener already set, and a later setToolCallListener call removes the hooks. A registry with no hooks does nothing: it sets no listener and fires no ON_START.

One registry can be applied to several agents. ON_START fires once for each applyTo call.