Skip to content

Extensions and hooks

An extension is a program nimlet starts and keeps running while it is open, talking over JSON lines on stdin and stdout. Any language works. While it runs, an extension can register tools the model can call, slash commands you can type, and lifecycle hooks that run before or after tool calls, turns, sessions, and compaction.

The difference from a tool.json tool: a tool is spawned per call with one JSON argument and one JSON result, then exits. An extension is a conversation - it starts once, gets told about events, and can push updates whenever it likes.

~/.agents/extensions/hello/extension.json portable, shared with other agents
~/.nimlet/extensions/hello/extension.json yours
<workspace>/.agents/extensions/hello/extension.json project (needs trust)
<workspace>/.nimlet/extensions/hello/extension.json

Every directory containing an extension.json is started. Two differences from skills and prompt templates are worth remembering:

  • Extensions are not overridden by name. Two extensions with the same name both run; if both register a tool with the same tool name, the last one loaded wins silently.
  • There is no .agent/extensions root. The project roots here are .agents and .nimlet, even though skills and tool.json tools also read .agent.

The project roots are only searched when you have trusted the project.

.nimlet/extensions/notes/extension.json
{
"name": "notes",
"command": ["./extension.py"],
"response_timeout_seconds": 30
}
Field Type Required Meaning
name string yes Used in warnings and as the namespace for status, widget, and entry keys
command string array yes Program and arguments. Non-empty. The first element is resolved against the extension folder when it contains /
response_timeout_seconds integer or null no Seconds nimlet waits for a reply. Default 30. null means no timeout. Time spent waiting for a user question does not count

The process starts with your workspace as its working directory.

On POSIX, make scripts executable with a shebang and use "command": ["./extension.py"]. On Windows, use a .cmd wrapper or pass a workspace-relative path to an interpreter, for example ["python", ".nimlet/extensions/notes/extension.py"]. .sh, .ps1, .cmd, and .bat files are launched through the matching interpreter.

A manifest that does not parse, or is missing name or command, is skipped with a startup warning such as skipping /path: missing name. Nothing crashes.

Rules:

  • One JSON object per line on stdin and stdout. No other stdout output.
  • Nimlet writes requests to your stdin. You write replies to stdout.
  • Every request has an id string. Every response must echo the same id.
  • Replies may arrive in any order; ids are how nimlet matches them.
  • Several requests can be in flight at once.
  • Stderr is not read. Write logs to a file.

Startup sequence:

  1. Nimlet starts your process with the workspace as the working directory.
  2. Nimlet writes initialize to your stdin (no reply expected).
  3. You write register to stdout. Nimlet waits for this line before continuing.
  4. Nimlet may send tool, command, event, cancel, or shutdown requests.
  5. You may send response, update, or ui_request lines at any time.

Most extensions print register immediately at startup, then enter a read loop.

Sent once when the extension starts. No reply.

{
"type": "initialize",
"version": 1,
"workspace": "/home/you/code/project",
"session_id": "1789233281025102"
}
Field Type Meaning
type "initialize"
version 1 Protocol version
workspace string Absolute path to the workspace
session_id string Current session id

On /reload, /new, /resume, or a trust change, extensions restart and you receive a fresh initialize with the current session id.

Your first stdout line. Nimlet blocks until it arrives or times out.

{
"type": "register",
"commands": [
{"name": "note", "description": "Append a note to NOTES.md"}
],
"tools": [
{
"name": "save_note",
"description": "Save a note about the current work.",
"input_schema": {
"type": "object",
"properties": {"text": {"type": "string"}},
"required": ["text"]
},
"capabilities": ["read"]
}
],
"events": ["tool_call", "tool_result"]
}
Field Type Required Meaning
type "register" yes
commands array yes May be empty. Slash commands you expose
tools array no Model-callable tools. Omit when you have none
events array of strings no Lifecycle hook names to subscribe to. Omit when you have none

commands[] entries:

Field Type Required Meaning
name string yes Becomes /name in the composer (case-insensitive)
description string no Shown in tab completion

tools[] entries:

Field Type Required Meaning
name string yes Tool name the model sees. Must not collide with a built-in
description string yes
input_schema object yes JSON Schema object for the tool arguments
capabilities string array no See Tool capabilities. Omitting means write, shell, and network

events[] values: one of the eight hook names in Lifecycle events.

Registration is validated strictly. Invalid register lines fail startup with a warning and the process is stopped.

The model called one of your registered tools.

{
"type": "tool",
"id": "7",
"name": "save_note",
"arguments": {"text": "remember the cache bug"}
}
Field Type Meaning
type "tool"
id string Reply id
name string Tool name from your register
arguments object Tool arguments from the model. {} when empty

Reply with content (string) and optional is_error (boolean, default false). You can include side-effect fields (status, widget, notification, entry) on the same response line.

You typed a registered slash command.

{
"type": "command",
"id": "3",
"name": "note",
"arguments": "remember the cache bug"
}
Field Type Meaning
type "command"
id string Reply id
name string Command name from your register
arguments string Plain text after the command. Empty string when you typed /note alone

Reply fields:

Field Type Meaning
message string Printed in the transcript
prompt string If set, becomes the next user message and starts a turn

Both can appear together: print message, then run prompt.

A lifecycle hook you subscribed to in register.events.

{
"type": "event",
"id": "9",
"event": "tool_call",
"payload": {
"tool": "bash",
"arguments": {"command": "npm test"}
}
}
Field Type Meaning
type "event"
id string Reply id
event string Hook name
payload object Event-specific fields. See Lifecycle events

You must reply to every event, even when you have nothing to change. An empty acknowledgement is enough:

{"type": "response", "id": "9"}

Sent when the user interrupts while nimlet is waiting for your reply to another request. No reply needed.

{"type": "cancel", "id": "7"}
Field Type Meaning
type "cancel"
id string The id of the request being cancelled

Nimlet is exiting or reloading. No reply needed. Stop your loop after this.

{"type": "shutdown"}

Reply to a tool, command, or event request. Always echo the request id.

{"type": "response", "id": "7", "content": "Saved.", "is_error": false}

Fields nimlet reads depend on what you are replying to:

Field Type Used for Meaning
type "response" all
id string all Must match the request
content string tool Tool result text
is_error boolean tool Whether the tool failed
message string command Text printed to the user
prompt string command Starts a new turn with this text
allow boolean tool_call, session_before_compact false blocks the action
reason string tool_call, session_before_compact Shown to the model or user when blocked
arguments object tool_call Replaces the tool arguments before execution
output string tool_result Replaces tool output
is_error boolean tool_result Replaces the error flag
instruction string session_before_compact Extra instruction for the summarizer
compaction object session_before_compact Full custom compaction. See below
status object any Footer status line
widget object any Lines above the footer
notification object any Transcript message
entry any JSON any Durable session data

compaction object (on session_before_compact replies):

Field Type Required Meaning
summary string yes Markdown summary written to the session
first_kept_index integer yes How many session events to keep verbatim after the summary
details any JSON no Stored in the session compaction record

Unsolicited side effects. No id required. Same side-effect fields as response:

{
"type": "update",
"status": {"key": "job", "text": "researching"},
"widget": {"key": "agents", "lines": ["✓ research", "… tests"]},
"notification": {"level": "info", "message": "Research complete"},
"entry": {"completed": ["research"]}
}

You can send update while a request is in flight, including from another thread, as long as each line is valid JSON.

Ask the user a multiple-choice question from inside a tool or command handler:

{
"type": "ui_request",
"id": "q1",
"method": "question",
"prompt": "Which environment?",
"options": ["staging", "production"]
}
Field Type Meaning
type "ui_request"
id string Matched on the response
method "question" Only supported method today
prompt string Question text
options string array Choices shown to the user

Nimlet replies on stdin:

{"type": "ui_response", "id": "q1", "answer": "staging", "cancelled": false}
Field Type Meaning
type "ui_response"
id string Matches your ui_request
answer string Selected option. Empty when dismissed
cancelled boolean true when there is no TUI to ask, or the user dismissed the prompt

Time waiting for ui_response does not count against response_timeout_seconds.

These are the only hook names you can list in register.events. Unknown names are accepted at registration but never fire.

Event When it fires
tool_call Before a tool executes
tool_result After a tool returns, before the result is saved
turn_start At the start of each agent turn
turn_end When a turn finishes or is interrupted
session_start When nimlet starts, and after /new, /resume, or /fork
session_end Before switching sessions, and when nimlet exits
session_before_compact Before compaction runs (auto or manual /compact)
session_compact After compaction finishes

When hooks do not run:

  • Plan mode - no lifecycle events are dispatched. Switch to act mode first.
  • ask_user - the built-in question tool does not trigger tool_call or tool_result hooks.

Which tools trigger tool_call / tool_result:

  • All built-in tools except ask_user
  • All extension tools you register
  • read, grep, glob, and read_skill can run in parallel when the model requests several in one step; each still gets its own hook round trip

Multiple extensions: if several extensions subscribe to the same event, nimlet asks each one in startup order. For tool_call and session_before_compact, any allow: false blocks the action; reasons are joined with ; . On other events, allow: false is ignored but you must still reply.

Payload:

Field Type Meaning
tool string Tool name (bash, read, your extension tool name, etc.)
arguments object Arguments the model sent. {} when empty

Reply fields that change behavior:

Field Effect
allow: false Blocks the tool. The model receives approval_denied with reason
arguments Replaces the arguments object before execution
{"type": "response", "id": "9", "allow": false, "reason": "Not while tests are running"}
{"type": "response", "id": "9", "arguments": {"path": "src/main.py"}}

Payload:

Field Type Meaning
tool string Tool name
arguments object Arguments that were used (after any tool_call rewrite)
output string Tool output text
is_error boolean Whether the tool failed

Reply fields that change behavior:

Field Effect
output Replaces the output string saved to the session
is_error Replaces the error flag

allow: false has no effect on this event.

Payload:

Field Type Meaning
session_id string
workspace string Absolute workspace path

Fires once per user message that starts an agent turn. Reply to acknowledge. allow: false has no effect. Use side-effect fields if you want to update the footer or store session data.

Payload:

Field Type Meaning
session_id string
workspace string
interrupted boolean Present and true when the user stopped the turn with Esc or Ctrl+C

Reply to acknowledge. allow: false has no effect.

Payload:

Field Type Meaning
session_id string
workspace string

Fires at nimlet startup and after /new, /resume, or /fork. Reply to acknowledge. allow: false has no effect.

Payload: same as session_start.

Fires before switching to another session and when nimlet exits. Reply to acknowledge. allow: false has no effect.

Payload:

Field Type Meaning
session_id string
workspace string
instruction string Manual /compact instruction, or empty for auto-compaction
tokens_before integer Estimated context tokens before compaction
entries array Full session as JSON. See Session entries

Reply fields that change behavior:

Field Effect
allow: false Skips compaction. reason is shown to the user
instruction Appended to the summarizer instruction (after any existing instruction, separated by a newline)
compaction Skips the built-in summarizer and uses your summary instead
{
"type": "response",
"id": "9",
"compaction": {
"summary": "## Goal\n",
"first_kept_index": 96,
"details": {"source": "my-extension"}
}
}

Payload:

Field Type Meaning
session_id string
workspace string
did_compact boolean Whether compaction actually ran
summary string Summary text, or empty when nothing was compacted
first_kept_index integer Events kept verbatim. 0 when nothing was compacted
tokens_before integer Estimated tokens before compaction
message string Status message (Auto-compacted context, Nothing to compact, etc.)

Fires after compaction completes. Reply to acknowledge. allow: false has no effect.

The entries field on session_before_compact is a JSON array of every event in the current session, in order. Each object has a type field:

type Fields Meaning
user role, content User message
assistant role, content, optional model, provider, requested_model, usage Assistant message
tool_result id, output, is_error, optional images Standalone tool result event
compaction summary, first_kept_index, tokens_before, optional details Prior compaction
extension extension, data Extension bookkeeping from entry replies
name name Session title from /name
selection provider, model Provider/model selection event

content arrays inside user and assistant messages contain typed parts:

Part type Fields
text text
tool_use id, name, input, optional parse_error, hosted, thought_signature
thinking thinking, signature
tool_result tool_use_id, content, is_error, optional images
image mimeType, path or data
file mimeType, path or data, optional filename
source url, title, optional id, cited_text, raw

The workspace path is not in entries. It is only on the session file header line.

These fields can appear on any response or unsolicited update line.

{"key": "job", "text": "researching"}
Field Type Meaning
key string Short identifier. Namespaced as <extension-name>:<key>
text string Shown in the footer. Empty string removes this status
{"key": "agents", "lines": ["✓ research", "… tests"], "placement": ""}
Field Type Meaning
key string Namespaced like status
lines string array Lines drawn above the footer. Empty array removes the widget
placement string Accepted but not used by the TUI today
{"level": "info", "message": "Research complete"}
Field Type Meaning
level string error and warning are styled. Anything else (including info) is plain text
message string Printed in the transcript

Arbitrary JSON stored in the session under your extension name:

{"count": 1, "phase": "research"}

Written as a session event:

{"type": "extension", "extension": "lifecycle", "data": {"count": 1}}

entry data is never sent to the model. It persists across /resume and is the right place for extension state you need when the process restarts.

Capability Meaning
read Reads data, changes nothing
user Talks to the user rather than the world
write Changes files
shell Runs commands
network Reaches the network

A tool whose capabilities are only read and/or user is offered in plan mode as well as act mode. Omitting capabilities counts as write, shell, and network, so the tool stays act-only.

Built-in names you cannot register: ask_user, bash, edit, git, glob, grep, read, read_skill, write.

Extension tools use the normal permission prompt the first time, keyed as tool:<name>. --tools filters extension tools the same way as built-ins.

Failures are fail-open: if an extension crashes, times out, or returns malformed JSON, nimlet reports a warning and the session continues without that extension’s contribution.

.nimlet/extensions/notes/extension.py
#!/usr/bin/env python3
import json, sys
def send(message):
sys.stdout.write(json.dumps(message) + "\n")
sys.stdout.flush()
send({"type": "register",
"commands": [{"name": "note", "description": "Append a note to NOTES.md"}],
"tools": [{
"name": "save_note",
"description": "Save a note about the current work.",
"input_schema": {"type": "object",
"properties": {"text": {"type": "string"}},
"required": ["text"]},
"capabilities": ["read"]
}]})
for line in sys.stdin:
message = json.loads(line)
kind = message.get("type")
if kind == "command":
with open("NOTES.md", "a") as handle:
handle.write(message["arguments"] + "\n")
send({"type": "response", "id": message["id"], "message": "Noted."})
elif kind == "tool":
with open("NOTES.md", "a") as handle:
handle.write(message["arguments"]["text"] + "\n")
send({"type": "response", "id": message["id"], "content": "Saved the note.",
"status": {"key": "notes", "text": "NOTES.md updated"}})
elif kind == "shutdown":
break

Make it executable (chmod +x extension.py), then /reload. /note remember the cache bug appends a line to NOTES.md, and the model can call save_note.

Registers for tool_call and turn_end. Denies blocked commands and counts them in a footer widget. Answers every event, including ones it does not act on:

.nimlet/extensions/guard/extension.py
#!/usr/bin/env python3
import json, sys
BLOCKED = ("curl ", "wget ", "nc ", "ssh ")
def send(message):
sys.stdout.write(json.dumps(message) + "\n")
sys.stdout.flush()
send({"type": "register", "commands": [], "events": ["tool_call", "turn_end"]})
blocked = 0
for line in sys.stdin:
message = json.loads(line)
kind = message.get("type")
if kind == "shutdown":
break
reply = {"type": "response", "id": message.get("id", "")}
if kind == "event" and message["event"] == "tool_call":
arguments = message["payload"].get("arguments") or {}
command = str(arguments.get("command", ""))
if any(word in command for word in BLOCKED):
blocked += 1
reply.update({
"allow": False,
"reason": "network commands are blocked in this project",
"widget": {"key": "guard", "lines": [f"blocked {blocked}"]},
})
send(reply)

The protocol is JSON lines over stdin and stdout. A single .mjs file and Node’s standard library are enough:

.nimlet/extensions/todos/extension.mjs
#!/usr/bin/env node
import { readdirSync } from 'node:fs'
import { readFile } from 'node:fs/promises'
import readline from 'node:readline'
const send = (message) => process.stdout.write(JSON.stringify(message) + '\n')
send({
type: 'register',
commands: [{ name: 'todos', description: 'List TODO and FIXME comments' }],
tools: [{
name: 'list_todos',
description: 'List TODO and FIXME comments as path:line text.',
input_schema: { type: 'object', properties: {} },
capabilities: ['read'],
}],
events: ['turn_end'],
})
async function handle(message) {
switch (message.type) {
case 'event':
send({ type: 'response', id: message.id })
break
case 'shutdown':
process.exit(0)
}
}
const input = readline.createInterface({ input: process.stdin })
for await (const line of input) {
if (line.trim()) await handle(JSON.parse(line))
}

On Windows, wrap .mjs files in a .cmd that calls node "%~dp0extension.mjs" %*.

Situation What happens
/reload, /new, /resume, trust change Extensions restart with a new initialize
Request timeout extension response timed out. The tool call fails; the turn continues
response_timeout_seconds: null No timeout. Use only when the extension always answers
shutdown Sent on clean exit. Process is terminated if still running after a brief wait
Invalid register Warning at startup. Extension is not loaded
Runtime parse error Warning. Extension keeps running
Built-in tool name collision Tool skipped with a warning

Debugging:

Terminal window
./.nimlet/extensions/notes/extension.py </dev/null
Terminal window
printf '%s\n' \
'{"type":"initialize","version":1,"workspace":"'"$PWD"'","session_id":"test"}' \
'{"type":"command","id":"1","name":"note","arguments":"hello"}' \
'{"type":"shutdown"}' | ./.nimlet/extensions/notes/extension.py

Stdout is the protocol. Write logs to a file, not stderr.

Mechanism Roots searched, in order Same name overrides?
Skills ~/.agents/skills, ~/.nimlet/skills, .agent/skills, .agents/skills, .nimlet/skills Yes
Prompt templates ~/.agents/prompts, ~/.nimlet/prompts, .agent/prompts, .agents/prompts, .nimlet/prompts Yes
tool.json tools ~/.nimlet/tools, .agent/tools, .nimlet/tools Yes
Extensions ~/.agents/extensions, ~/.nimlet/extensions, .agents/extensions, .nimlet/extensions No, every one starts

Project roots are skipped until you trust the project.