JSON hooks
JSON hooks let you react to LLM events — messages, stream chunks, tool calls —
by writing a JSON file instead of TypeScript. You drop a .json file into a
directory, point the service at it, and the hook fires automatically.
How it works
A hook definition has three parts:
target— what to listen to ("chat","stream", or"tool")- Filters — optional fields to narrow when the hook fires (e.g.
roles,regex,chunks) actions— what to do when it fires (log, interrupt, queue a message, …)
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 your-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 lets you target 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", "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. You 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 you omit message, a default JSON dump is used so you can see what data is
available 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 can cap this.
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
Tool role note: When role is "tool", an auto-generated ID is created
(inject-<timestamp>). This lets you supply synthetic tool results.
queue-resend
Set the resend flag without aborting the current request. The send() loop
retries once the current stream finishes. Useful when you want to add messages
to the queue and trigger 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}} |
| 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 lets you log, queue a message,
and interrupt 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: config value > env var > unset (no hooks loaded).