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

FieldTypeRequiredDescription
namestringYesUnique tool identifier
descriptionstringYesSent to the LLM — explains what the tool does and when to use it
inputSchemaobjectYesJSON Schema for tool arguments
outputSchemaobjectNoJSON Schema for tool output
timeoutnumberNoExecution timeout in ms (default: 30000)
envstring[]NoEnvironment 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:

TypeUse for
diffSingle file diff
diffsMultiple file diffs
codeFull file content display
file-listList of files with actions
tableStructured tabular data
markdownRendered markdown content
shell-outputTerminal command output
todo-listChecklist with statuses
noneExplicit "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 };
  }
}