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:

  1. 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 workspace
    • sessionId — the current session ID
    { "path": "/project/src/index.ts", "workspacePath": "/project", "sessionId": "abc123" }
    
  2. Output: Your script writes a JSON object to stdout and exits with code 0:

    { "result": "File processed successfully" }
    
  3. Error: Exit with non-zero code, or include an error field:

    { "error": "File not found" }
    
  4. Working directory: Set to workspacePath if provided, otherwise the server's cwd.

  5. 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 message in 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_REJECTION error

If onPermissionRequest callback 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 _visualization key 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:

  1. The server persists the full result to a file in the temp directory
  2. A preview (first 10,000 characters) is returned to the LLM
  3. A note is appended telling the LLM the file path so it can use read-file if 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 }));
}