Writing Tools
Create custom tools for Jean2 using the TypeScript SDK — with typed context, the Ask Protocol, and rich visualization.
Tools are TypeScript modules that export a definition and an execute function. The server loads them dynamically. No build step, no registration, no restart needed.
Quick start
Create a folder in ~/.jean2/tools/{your-tool-name}/ with a tool.ts and a package.json. The server picks it up automatically (scans every 60 seconds, or immediately after jean2 tools install). Delete the folder to remove it.
Folder structure
~/.jean2/tools/my-tool/
├── tool.ts # Tool definition + logic (required)
├── package.json # Dependencies (required)
└── VERSION # Semantic version, e.g. "1.0.0" (optional)
package.json
Your tool needs @jean2/sdk as a dependency.
{
"name": "@jean2/tool-my-tool",
"version": "1.0.0",
"private": true,
"type": "module",
"dependencies": {
"@jean2/sdk": "^0.9.0"
}
}
The server installs dependencies automatically. You don't need to run npm install.
tool.ts
Every tool exports two things: a definition and an execute function.
import type { ToolDefinition, ToolContext, ToolResult } from '@jean2/sdk';
interface Input {
// your input fields here
}
export const definition: ToolDefinition = {
name: 'my-tool',
description: 'What this tool does. This text is sent to the LLM.',
inputSchema: {
type: 'object',
properties: {
path: {
type: 'string',
description: 'The file path to process',
},
},
required: ['path'],
},
timeout: 30000,
};
export async function execute(input: Input, ctx: ToolContext): Promise<ToolResult> {
try {
const resolvedPath = ctx.resolvePath(input.path);
const content = await ctx.fs.readFile(resolvedPath, 'utf-8');
return {
success: true,
result: { content },
};
} catch (err: unknown) {
const message = err instanceof Error ? err.message : String(err);
return { success: false, error: message };
}
}
ToolDefinition fields
| Field | Type | Required | Description |
|---|---|---|---|
name | string | Yes | Unique tool identifier |
description | string | Yes | Sent to the LLM — explains what the tool does and when to use it |
inputSchema | object | Yes | JSON Schema for tool arguments |
outputSchema | object | No | JSON Schema for tool output |
timeout | number | No | Execution timeout in ms (default: 30000) |
env | string[] | No | Environment variables this tool needs access to |
The ToolContext
The ctx parameter gives your tool typed access to the server's capabilities.
interface ToolContext {
sessionId: string;
workspacePath: string;
workspaceId?: string;
abortSignal: AbortSignal;
allowedPaths: string[];
fs: FileSystemApi;
llm: LlmApi;
ask: AskApi;
env: EnvApi;
logger: ToolLogger;
fetch: typeof globalThis.fetch;
resolvePath(path: string): string;
isWithinWorkspace(path: string): boolean;
isSensitivePath(path: string): boolean;
isBlockedPath(path: string): boolean;
}
FileSystem API (ctx.fs)
Type-safe file operations. All paths are resolved relative to the workspace.
const content = await ctx.fs.readFile('/path/to/file', 'utf-8');
await ctx.fs.writeFile('/path/to/file', 'content');
await ctx.fs.appendFile('/path/to/file', 'more content');
const entries = await ctx.fs.readDir('/path/to/dir');
const exists = await ctx.fs.exists('/path/to/file');
const stat = await ctx.fs.stat('/path/to/file');
await ctx.fs.mkdir('/path/to/dir', { recursive: true });
await ctx.fs.rm('/path/to/file');
await ctx.fs.rename('/old/path', '/new/path');
const absolute = ctx.fs.resolve('relative/path');
const lang = ctx.fs.detectLanguage('file.ts'); // 'typescript'
ctx.fs.tempDir gives you a session-scoped temporary directory.
Environment API (ctx.env)
Tools don't inherit the server's environment. Access only the variables you declared in definition.env.
const cacheDir = ctx.env.get('MY_TOOL_CACHE_DIR');
const required = ctx.env.require('MY_API_KEY'); // throws if not set
Declare the variables your tool needs.
export const definition: ToolDefinition = {
name: 'my-tool',
// ...
env: ['MY_TOOL_CACHE_DIR', 'MY_TOOL_TIMEOUT'],
};
Set them in ~/.jean2/.env:
MY_TOOL_CACHE_DIR=/data/cache
MY_TOOL_TIMEOUT=30
API keys and other secrets are never exposed to tool processes. The tool only receives variables it explicitly asked for.
LLM API (ctx.llm)
Your tool can call an LLM for processing.
const response = await ctx.llm.generateText({
prompt: 'Summarize this text:\n' + content,
system: 'You are a concise summarizer.',
});
const structured = await ctx.llm.generateStructured<{ summary: string }>({
prompt: 'Summarize this text:\n' + content,
schema: {
type: 'object',
properties: { summary: { type: 'string' } },
required: ['summary'],
},
});
Logger (ctx.logger)
ctx.logger.debug('Processing file', { path, size });
ctx.logger.info('File processed', { lines: 42 });
ctx.logger.warn('Large file', { size: '10MB' });
ctx.logger.error('Failed to read', { error: err.message });
Path utilities
const absolute = ctx.resolvePath('./src/index.ts'); // resolves relative to workspace
ctx.isWithinWorkspace('/some/path'); // true if inside workspace
ctx.isSensitivePath('/project/.env'); // true for .env, .key, .pem, etc.
ctx.isBlockedPath('/etc/passwd'); // true for system directories
The Ask Protocol
Tools interact with the user through ctx.ask(). A unified typed channel for permissions, questions, confirmations, and forms. The client handles the UI.
Permission asks
The most common use: requesting user approval before a sensitive action.
import { createFilePermissionAsk } from '@jean2/sdk';
const approved = await ctx.ask(
createFilePermissionAsk({
path: input.path,
operation: 'read',
risk: 'medium',
isOutsideWorkspace: true,
})
);
if (!approved) {
return { success: false, error: 'USER_REJECTION' };
}
You can also construct asks directly:
const approved = await ctx.ask({
type: 'permission',
question: 'Read file outside workspace?',
risk: 'medium',
resource: 'file',
paths: [input.path],
});
Human questions
Ask the user a question and get a typed response.
// Single select
const answer = await ctx.ask({
target: 'human',
type: 'single_select',
question: 'Which environment?',
options: [
{ label: 'Production', value: 'prod' },
{ label: 'Staging', value: 'staging' },
],
});
// Text input
const branch = await ctx.ask({
target: 'human',
type: 'text',
question: 'Branch name?',
placeholder: 'main',
});
// Confirmation
const confirmed = await ctx.ask({
target: 'human',
type: 'confirm',
question: 'Delete all temporary files?',
defaultValue: false,
});
// Multi-select form
const answers = await ctx.ask({
target: 'human',
type: 'form',
question: 'Deployment configuration',
questions: [
{ type: 'text', question: 'Environment?', placeholder: 'production' },
{ type: 'confirm', question: 'Run migrations?', defaultValue: true },
],
});
Visualization
By default, the client shows raw tool input and output. Add a visualization to your return value for rich, formatted display.
import type { CodeVisualization } from '@jean2/sdk';
return {
success: true,
result: { path: resolvedPath },
visualization: {
type: 'code',
path: resolvedPath,
content: fileContent,
language: 'typescript',
created: true,
lineCount: 42,
} satisfies CodeVisualization,
};
The visualization field is stored in the database and rendered by the client, but stripped before sending to the LLM. Rich UI data without token bloat.
9 visualization types:
| Type | Use for |
|---|---|
diff | Single file diff |
diffs | Multiple file diffs |
code | Full file content display |
file-list | List of files with actions |
table | Structured tabular data |
markdown | Rendered markdown content |
shell-output | Terminal command output |
todo-list | Checklist with statuses |
none | Explicit "nothing to show" |
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.
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, with a note appended telling the LLM the file path so it can use read-file if needed.
Just return your result normally. The server handles the rest.
Full example: Word count
package.json:
{
"name": "@jean2/tool-word-count",
"version": "1.0.0",
"private": true,
"type": "module",
"dependencies": {
"@jean2/sdk": "^0.9.0"
}
}
tool.ts:
import type { ToolDefinition, ToolContext, ToolResult } from '@jean2/sdk';
import type { NoneVisualization } from '@jean2/sdk';
interface Input {
path: string;
}
export const definition: ToolDefinition = {
name: 'word-count',
description: 'Count words, lines, and characters in a file.',
inputSchema: {
type: 'object',
properties: {
path: {
type: 'string',
description: 'Path to the file to count words in',
},
},
required: ['path'],
},
timeout: 10000,
};
export async function execute(input: Input, ctx: ToolContext): Promise<ToolResult> {
try {
const resolvedPath = ctx.resolvePath(input.path);
if (ctx.isBlockedPath(resolvedPath)) {
return { success: false, error: `Cannot access: ${input.path}` };
}
if (!ctx.isWithinWorkspace(resolvedPath)) {
const approved = await ctx.ask({
type: 'permission',
question: `Read file outside workspace: ${input.path}?`,
risk: 'medium',
resource: 'file',
});
if (!approved) return { success: false, error: 'USER_REJECTION' };
}
const content = await ctx.fs.readFile(resolvedPath, 'utf-8');
const words = content.split(/\s+/).filter(Boolean).length;
const lines = content.split('\n').length;
const characters = content.length;
const visualization: NoneVisualization = {
type: 'none',
message: `Word count: ${resolvedPath}`,
};
return {
success: true,
result: { words, lines, characters },
visualization,
};
} catch (err: unknown) {
const message = err instanceof Error ? err.message : String(err);
return { success: false, error: message };
}
}