Skip to content

JSON hooks

JSON hooks react to LLM events (messages, stream chunks, tool calls) by writing a JSON file instead of TypeScript. A .json file dropped into a directory, with the service pointed at it, fires the hook automatically.

JSON hooks: declared in config.hooksDir, targeting chat, stream or tool


How it works

A hook definition has three parts:

  1. target: what to listen to ("chat", "stream", or "tool")
  2. Filters: optional fields to narrow when the hook fires (e.g. roles, regex, chunks)
  3. actions: what to do when it fires (log, interrupt, queue a message, …)

A definition may also set label (used in logs and template variables) and enabled; enabled: false skips registration, and it defaults to true.

When the hook fires, each action in the actions array runs in order. If an action produces output (like log or queue-message), it can use {{variable}} templates to insert event data.


Minimal example

Create a file hooks/watch-content.json:

{
    "target": "stream",
    "chunks": ["content"],
    "actions": [
        { "type": "log", "message": "[stream] {{text}}" }
    ]
}

Set the hooks directory and run:

LLM_CHAT_HOOKS_DIR=./hooks node app.js

Every content chunk the LLM streams out is now logged to the console:

[stream] Hello
[stream]  World
[stream] !

Targets

chat: react to messages

Fires when a message is added to the chat history. This includes user messages, assistant responses, tool results: any message except those with origin: Hook (see queue-message below).

Filter Type Default Description
roles string[] all roles Only fire for specific roles ("user", "assistant", "tool", "reasoning")
regex string — Only fire when the message content matches this pattern
maxTriggers number unlimited Stop firing after this many times
{
    "label": "warn-on-error",
    "target": "chat",
    "roles": ["assistant"],
    "regex": "error|Error|warning",
    "actions": [
        { "type": "warn", "message": "[{{role}}] {{content}}" }
    ]
}

When the assistant says something matching "error" or "warning", the hook logs it to console.warn. The regex filter targets specific content patterns; without it the hook fires on every message of those roles.


stream: react to stream chunks

Fires during LLM streaming, once per chunk. Chunks arrive as the model generates tokens, so the hook fires multiple times per response.

Filter Type Default Description
chunks string[] all chunk types Filter by chunk type ("content", "reasoning", "tool_call_delta", "usage", "finish")
{
    "label": "watch-thinking",
    "target": "stream",
    "chunks": ["reasoning"],
    "actions": [
        { "type": "log", "message": "🧠 {{text}}" }
    ]
}

This logs only reasoning chunks (the model's internal chain-of-thought) without printing the final response or tool call metadata.


tool: react to tool execution

Fires when a tool runs. A hook can listen before execution, after execution, or on error.

Filter Type Default Description
tools string[] all tools Only fire for specific tool names
event "before" | "after" | "error" "after" When to fire
{
    "label": "track-fetch",
    "target": "tool",
    "tools": ["fetch_page"],
    "event": "after",
    "actions": [
        { "type": "log", "message": "fetch_page returned {{result}}" }
    ]
}
{
    "label": "alert-failure",
    "target": "tool",
    "event": "error",
    "actions": [
        { "type": "warn", "message": "Tool {{name}} failed: {{error}}" }
    ]
}

The first logs the result of fetch_page after it runs. The second warns on any tool error (no tools filter means all tools).


Actions

Actions are the "what happens next" of a hook. Each action is an object in the actions array. The type field selects the behaviour; extra fields configure it.

log / warn / info / debug

Print a message to the console at the matching log level.

{ "type": "log", "message": "{{content}}" }
Field Required Default Description
message no [json-hook] target: {…} Template with {{variable}} placeholders

If message is omitted, a default JSON dump shows the available data while prototyping.


interrupt

Abort the current LLM request mid-flight. No further chunks are processed; the response is discarded.

{ "type": "interrupt" }

Use this to stop generation when unwanted content is detected, for example if the model starts leaking sensitive data:

{
    "label": "stop-on-secret",
    "target": "stream",
    "chunks": ["content"],
    "regex": "api_key|password|secret",
    "actions": [
        { "type": "interrupt" }
    ]
}

interrupt does not use message; it only aborts.

resend: true: auto-retry

Add "resend": true to automatically retry the request after aborting:

{
    "label": "retry-on-error",
    "target": "stream",
    "chunks": ["content"],
    "regex": "error|Error",
    "actions": [
        { "type": "interrupt", "resend": true }
    ]
}

When resend is true, send() loops internally. Each retry sends the same chat history again and the LLM generates a fresh response. If the new response also triggers the hook, it retries again; maxTriggers caps this for chat-target hooks.


queue-message

Queue a synthetic message that is sent on the next LLM request. The message sits in a queue and is drained when send() runs. Messages queued this way are tagged with origin: Hook.

Chat hooks with target: "chat" silently skip Hook-origin messages to prevent infinite loops: a hook that queues a message won't re-trigger itself when that message is drained into chat history.

{ "type": "queue-message", "role": "user", "message": "Please clarify." }
Field Required Default Description
role no "assistant" One of "user", "assistant", "tool", "reasoning"
message no [json-hook] target: {…} Content of the queued message

Use case: steer the conversation. When the assistant says "I don't know", queue a user message that pushes it to try again:

{
    "label": "correct-and-retry",
    "target": "chat",
    "roles": ["assistant"],
    "regex": "I don't know|I'm not sure",
    "actions": [
        { "type": "queue-message", "role": "user", "message": "Please try to answer the question." },
        { "type": "interrupt", "resend": true }
    ]
}

The sequence: 1. Assistant says "I don't know" 2. queue-message queues a user message asking it to try again 3. interrupt aborts the current response 4. resend: true triggers a retry; the original messages plus the queued user message are sent together 5. The model generates a new answer with the extra nudge

When role is "tool", an auto-generated ID is created (inject-<timestamp>). This allows supplying synthetic tool results.


queue-resend

Set the resend flag without aborting the current request. The send() loop retries once the current stream finishes. Useful for adding messages to the queue and triggering a retry without interrupting the ongoing response.

{ "type": "queue-resend" }

This action has no extra fields; it only sets the flag. Combine it with queue-message to queue a message and then trigger a retry:

{
    "label": "queue-and-resend",
    "target": "chat",
    "roles": ["assistant"],
    "regex": "I don't know",
    "actions": [
        { "type": "queue-message", "role": "user", "message": "Try again." },
        { "type": "queue-resend" }
    ]
}

The current stream completes normally, the queued message is included on the next send() loop iteration.


Template variables

The message field in any action supports {{variable}} placeholders. Which variables are available depends on the target:

Target Variables
chat {{label}}, {{target}}, {{role}}, {{content}}, {{match}} (regex capture)
stream: content/reasoning {{label}}, {{target}}, {{type}}, {{text}}
stream: tool_call_delta {{label}}, {{target}}, {{type}}, {{text}}, {{toolCallId}}
stream: finish {{label}}, {{target}}, {{type}}, {{finishReason}}
stream: usage {{label}}, {{target}}, {{type}}
tool {{label}}, {{target}}, {{event}}, {{name}}, {{args}}, {{result}}, {{error}}

If a variable is missing from the event data it is left as-is in the output ({{missing}} stays {{missing}}).


Combining actions

The actions array runs actions in order. This allows logging, queueing a message, and interrupting in one shot:

{
    "label": "full-cycle",
    "target": "chat",
    "roles": ["assistant"],
    "regex": "I don't know",
    "actions": [
        { "type": "log", "message": "Triggered retry for: {{content}}" },
        { "type": "queue-message", "role": "user", "message": "Please try again." },
        { "type": "interrupt", "resend": true }
    ]
}

Order matters: queue-message runs before interrupt, so the message is already queued when the abort-and-retry happens.


Multiple hooks, multiple files

A single file can contain one definition (an object) or several (an array):

[
    {
        "label": "log-stream",
        "target": "stream",
        "actions": [{ "type": "log", "message": "{{text}}" }]
    },
    {
        "label": "log-errors",
        "target": "chat",
        "roles": ["assistant"],
        "regex": "error",
        "actions": [{ "type": "warn", "message": "{{content}}" }]
    }
]

Or split them across files; every .json file in the hooks directory is loaded independently.


Inspecting registered hooks

Call service.getJsonHooks() to list all loaded hooks:

const hooks = service.getJsonHooks();
// → [{ label: 'log-errors', target: 'chat' }, …]

Each entry has label (always a string, empty if not set in the definition) and target ("chat", "stream", or "tool"). This is useful for debugging, displaying active hooks in a UI, or checking that expected hooks are registered.


Configuration

LLM_CHAT_HOOKS_DIR=./hooks
config.hooksDir = './my-hooks';

Priority: env var > config value > unset (no hooks loaded).