Writing Tools
Create custom tools for Jean2 with any runtime, security checks, visualization, and context overflow protection.
Writing Tools
Create custom tools for Jean2 with any runtime, security checks, visualization, and context overflow protection.
Quick Start: Drop a Folder
The simplest way to add a tool: create a folder in ~/.jean2/tools/{your-tool-name}/ with a tool.md and a script file. The server picks it up automatically (scans every 60 seconds, or immediately after jean2 tools install). Delete the folder to remove it.
No build step. No registration. No restart needed.
Folder Structure
~/.jean2/tools/my-tool/
├── tool.md # Tool definition (required)
├── script.ts # Tool logic (required — name must match `script` field in tool.md)
└── security.ts # Security gate (optional)
You can also use tool.json instead of tool.md (pure JSON, no markdown body).
tool.md Format
YAML frontmatter + markdown body:
---
name: my-tool
script: script.ts
runtime: bun
timeout: 30000
requireApproval: false
dangerous: false
hasSecurityCheck: false
inputSchema:
type: object
properties:
path:
type: string
description: "The file path to process"
required:
- path
outputSchema:
type: object
properties:
result:
type: string
error:
type: string
---
Human-readable description of what this tool does and when to use it.
This body text is sent to the LLM as part of the tool definition —
it tells the model how and when to call your tool.
Be specific about:
- What the tool does
- When to use it vs. alternatives
- Any important constraints or gotchas
Frontmatter fields:
| Field | Type | Required | Description |
|---|---|---|---|
| name | string | Yes | Unique tool identifier |
| script | string | Yes | Script filename relative to tool directory |
| runtime | string | Yes | One of: bun, node, python, bash, go, binary, powershell |
| inputSchema | object | Yes | JSON Schema for tool arguments |
| outputSchema | object | Yes | JSON Schema for tool output |
| timeout | number | No | Execution timeout in ms (default: 30000) |
| requireApproval | boolean | No | Whether the tool always requires user approval |
| dangerous | boolean | No | Metadata flag — displayed with ⚠ in tool listings |
| hasSecurityCheck | boolean | No | Whether a security.ts script should run before execution |
| securityScript | string | No | Custom security script filename (default: security.ts) |
| securityTimeout | number | No | Security check timeout in ms (default: 10000) |
| env | string[] | No | Environment variables this tool needs access to. Only these are passed to the tool process (in addition to built-in ones like workspacePath, sessionId). Set to [] to block all custom env vars. |
Supported Runtimes
| Runtime | Command | Example script |
|---|---|---|
| bun | bun run script.ts |
TypeScript with Bun APIs |
| node | node script.js |
Plain JavaScript |
| python | python3 script.py |
Python 3 |
| bash | bash script.sh |
Shell scripts |
| go | go run script.go |
Go source files |
| binary | ./script (direct) |
Pre-compiled executables |
| powershell | pwsh -File script.ps1 |
PowerShell scripts |
Execution Model
The server spawns your script as a child process:
-
Input: Jean2 sends a JSON object via stdin containing:
- All fields from your
inputSchema(the args the LLM provided) workspacePath— absolute path to the current workspacesessionId— the current session ID
{ "path": "/project/src/index.ts", "workspacePath": "/project", "sessionId": "abc123" } - All fields from your
-
Output: Your script writes a JSON object to stdout and exits with code 0:
{ "result": "File processed successfully" } -
Error: Exit with non-zero code, or include an
errorfield:{ "error": "File not found" } -
Working directory: Set to
workspacePathif provided, otherwise the server's cwd. -
Environment: Inherited from the server (see Environment Variables below).
Environment Variables
Tools don't inherit the server's environment. Each tool gets a clean environment containing only safe system variables (PATH, HOME, USER, SHELL, TMPDIR, etc.) plus whatever custom variables it explicitly declares in tool.md:
---
name: my-tool
script: script.ts
runtime: bun
env:
- MY_TOOL_CACHE_DIR
- MY_TOOL_TIMEOUT
---
The server passes only the listed variables to your tool. Access them via process.env:
const cacheDir = process.env.MY_TOOL_CACHE_DIR;
const timeout = parseInt(process.env.MY_TOOL_TIMEOUT || '30', 10);
Set your custom variables in ~/.jean2/.env:
MY_TOOL_CACHE_DIR=/data/cache
MY_TOOL_TIMEOUT=30
If env is omitted, no custom variables from .env are passed — only the safe system variables. Set env: [] for the same effect explicitly.
API keys and other secrets are never exposed to tool processes, not because they're filtered, but because the tool never receives variables it didn't ask for.
Security Checks
If hasSecurityCheck: true, the server runs security.ts before your tool script. It receives the exact same input as the tool itself.
security.ts must return via stdout:
{
"allowed": true,
"requiresApproval": false,
"permissionType": "file_write",
"permissionKey": "/path/to/file.ts",
"message": "This tool wants to write to file.ts"
}
The four outcomes:
| allowed | requiresApproval | Result |
|---|---|---|
| true | false | Tool executes immediately |
| false | false | Tool is blocked — error returned to LLM |
| true | true | Same as allowed — no prompt needed |
| false | true | Permission prompt shown to user in client |
Permission prompt flow:
- User sees the tool name, args, and your
messagein the client - User can Allow once or Always allow (cached per workspace)
- "Always allow" is keyed by
(toolName, permissionType, permissionKey)— so you can make it granular (e.g., per-file, per-directory) - If user denies, the tool receives
USER_REJECTIONerror
If
onPermissionRequestcallback isn't configured and approval is required, the tool is denied by default for safety.
Visualization
By default, the client shows raw tool input and output — functional but hard to read. Your tool can return a _visualization object for rich, formatted display.
Add _visualization to your output:
{
"content": "Line 1\nLine 2\nLine 3",
"_visualization": {
"type": "code",
"path": "src/index.ts",
"content": "Line 1\nLine 2\nLine 3",
"language": "typescript",
"created": false,
"lineCount": 3
}
}
The
_visualizationkey is stored in the database and rendered by the client, but it is stripped from the result before sending to the LLM API. This means you can include rich UI data without inflating token costs with information the model doesn't need.
9 visualization types:
diff — Single file diff
{
"type": "diff",
"path": "src/index.ts",
"language": "typescript",
"hunks": [
{
"oldStart": 10, "oldLines": 1, "newStart": 10, "newLines": 2,
"changes": [
{ "type": "removed", "content": "old line", "oldLineNumber": 10 },
{ "type": "added", "content": "new line 1", "newLineNumber": 10 },
{ "type": "added", "content": "new line 2", "newLineNumber": 11 }
]
}
],
"additions": 2,
"deletions": 1
}
diffs — Multiple diffs (for multiedit, apply-patch)
{ "type": "diffs", "items": [/* DiffVisualization[], ... */] }
code — Full file content display
{
"type": "code",
"path": "src/new-file.ts",
"content": "// full file content here...",
"language": "typescript",
"created": true,
"lineCount": 42,
"highlightLines": [10, 11, 12]
}
file-list — List of files with actions
{
"type": "file-list",
"groups": [
{
"label": "Modified",
"icon": "edit",
"files": [
{ "path": "src/index.ts", "action": "modified" },
{ "path": "src/utils.ts", "action": "modified", "line": 42 }
]
},
{
"label": "Created",
"icon": "plus",
"files": [{ "path": "src/new.ts", "action": "created" }]
}
],
"total": 3
}
table — Structured tabular data
{
"type": "table",
"columns": [
{ "key": "name", "label": "Name" },
{ "key": "status", "label": "Status", "width": "100px" }
],
"rows": [
{ "name": "server", "status": "running" },
{ "name": "database", "status": "stopped" }
],
"totalRows": 2
}
markdown — Rendered markdown content
{
"type": "markdown",
"content": "# Title\n\nParagraph with **bold** text.",
"sourceUrl": "https://example.com"
}
shell-output — Terminal command output
{
"type": "shell-output",
"command": "npm test",
"stdout": "Tests: 42 passed\n",
"stderr": "",
"exitCode": 0
}
todo-list — Checklist with statuses
{
"type": "todo-list",
"items": [
{ "content": "Set up database", "status": "completed", "priority": "high" },
{ "content": "Write API endpoint", "status": "in_progress", "priority": "medium" },
{ "content": "Add tests", "status": "pending", "priority": "low" }
]
}
none — Explicit "nothing to show"
{ "type": "none", "message": "Operation completed silently" }
All types support optional title (string) and collapsed (boolean) fields.
Context Overflow Protection
The server automatically truncates tool results that exceed 50,000 characters. You don't need to handle this in your tool.
What happens when a result is too large:
- The server persists the full result to a file in the temp directory
- A preview (first 10,000 characters) is returned to the LLM
- A note is appended telling the LLM the file path so it can use
read-fileif needed
The persisted file includes metadata (_persisted, _filePath, _originalSize) that is stripped before sending to the LLM — so token costs stay under control.
If your tool produces large output, just return it normally. The server handles the rest. You can still use the /tmp/jean2/{sessionId}/ convention for files your tool creates intentionally (e.g., fetched web pages), but the 50k safety net works regardless.
Full Example: A Simple Tool
tool.md:
---
name: word-count
script: script.ts
runtime: bun
inputSchema:
type: object
properties:
path:
type: string
description: "Path to the file to count words in"
required:
- path
outputSchema:
type: object
properties:
words:
type: number
lines:
type: number
characters:
type: number
timeout: 10000
---
Count words, lines, and characters in a file.
script.ts:
import { readFileSync } from 'node:fs';
const input = JSON.parse(await Bun.stdin.text());
const { path } = input;
try {
const content = readFileSync(path, 'utf-8');
const words = content.split(/\s+/).filter(Boolean).length;
const lines = content.split('\n').length;
const characters = content.length;
console.log(JSON.stringify({ words, lines, characters }));
} catch (e) {
console.log(JSON.stringify({ error: e.message }));
}