TOOL.md — Guide to Implementing a New Tool¶
This document explains how to create a new tool for the janito framework. It covers the architecture, the required specification, and a complete step-by-step walkthrough with a working example.
Table of Contents¶
- Architecture Overview
- Key Source Files
- The Tool Specification
- Step-by-Step: Create a New Tool
- Schema Generation (How the LLM Sees Your Tool)
- The Permission System
- The
should_load()Gate - Progress Reporting
- Toolset Organization and Discovery
- Return Value Contract
- CLI Testing Harness
- Complete Worked Example
- Checklist Before Submitting
Architecture Overview¶
janito/
├── tooling/ # Framework / infrastructure
│ ├── base_tool.py # BaseTool ABC — every tool inherits this
│ ├── tools_registry.py # Registry: discovery, schema gen, lookup
│ ├── reporter.py # Standalone progress-report helpers
│ └── path_utils.py # norm_path() helper
│
├── tools/ # Actual tool implementations
│ ├── decorator.py # @tool(permissions="…") decorator
│ ├── files/ # "files" toolset (autoloaded)
│ │ ├── create_file.py
│ │ ├── read_file.py
│ │ └── …
│ ├── system/ # "system" toolset (autoloaded)
│ │ ├── run_python_code.py
│ │ ├── run_bash_code.py
│ │ └── …
│ ├── gmail/ # "gmail" toolset (loaded on demand)
│ └── onedrive/ # "onedrive" toolset (loaded on demand)
│
└── privileges.py # Runtime privilege flags (-r / -w / -x)
Lifecycle of a tool call:
- Discovery —
discover_toolsets()scans a toolset directory, imports every.pymodule, and finds classes decorated with@tool. - Gating — each candidate is checked via
should_load()(environment sanity) and_check_tool_privileges()(CLI privilege flags). - Wrapping — the class is wrapped in a callable whose
__name__,__doc__,__signature__, and__annotations__mirror the class and itsrun()method. The wrapper is stored inAVAILABLE_TOOLS[ClassName]. - Schema export —
get_function_schema()reads the wrapper's metadata and produces an OpenAI-compatible function-calling schema. - Invocation — when the LLM calls the tool, the wrapper instantiates
the class and calls
instance.run(**kwargs).
Key Source Files¶
| File | Purpose |
|---|---|
janito/tooling/base_tool.py |
BaseTool abstract base class with run() and progress-reporting methods |
janito/tools/decorator.py |
@tool(permissions="…") decorator — marks a class as a discoverable tool |
janito/tools/__init__.py |
discover_toolsets() — auto-discovery engine |
janito/tooling/tools_registry.py |
Registry, get_function_schema(), lazy init, toolset management |
janito/tooling/reporter.py |
Standalone report_start / report_result / report_error functions |
janito/tooling/path_utils.py |
norm_path() — normalises paths relative to CWD for display |
janito/privileges.py |
Privileges dataclass and running_privileges global |
The Tool Specification¶
Every tool must satisfy the following contract:
1. Class-based, inherits BaseTool¶
2. Decorated with @tool¶
from janito.tools.decorator import tool
@tool(permissions="r") # "r", "w", "x", or combinations like "rw", "rwx"
class MyTool(BaseTool):
...
The decorator sets two attributes on the class:
_is_tool = True— discovery filter_tool_permissions = "<perms>"— used by privilege gating and colour-coded reporting
3. Implements run(self, **kwargs) -> Dict[str, Any]¶
- The first line of the class docstring becomes the tool description the LLM sees.
- Parameter types are inferred from
run()'s type hints (str,int,float,bool,Optional[T],List[T]). - Parameters without a default value become required in the schema.
- The method must always return a
Dict[str, Any]containing at minimum a"success"boolean key (see Return Value Contract).
4. (Optional) Overrides should_load(cls) -> bool¶
A @classmethod that returns False when the tool's runtime requirements are
not met (missing binary, unsupported OS, absent credentials, …). Set
cls._load_skip_reason to a human-readable explanation before returning
False.
5. (Recommended) Provides a main() CLI harness¶
A main() function guarded by if __name__ == "__main__" allows developers
to test the tool directly from the command line.
Step-by-Step: Create a New Tool¶
We will create a WordCount tool inside the existing files toolset.
Step 1 — Pick (or create) a toolset directory¶
Existing toolsets live under janito/tools/<toolset>/.
For a brand-new toolset, create the directory and an __init__.py:
mkdir janito/tools/mytoolset
touch janito/tools/mytoolset/__init__.py # can be an empty docstring module
For our example we reuse janito/tools/files/.
Step 2 — Create the tool module¶
Create janito/tools/files/word_count.py:
#!/usr/bin/env python3
"""
Word Count Tool - Counts words, lines, and characters in a file.
"""
import os
import json
from typing import Dict, Any
from ...tooling import BaseTool, norm_path
from ..decorator import tool
@tool(permissions="r") # read-only → "r"
class WordCount(BaseTool):
"""
Tool for counting words, lines, and characters in a text file.
"""
def run(self, filepath: str) -> Dict[str, Any]:
"""
Count words, lines, and characters in a text file.
Args:
filepath (str): Path to the text file to analyse.
Returns:
Dict[str, Any]: A dictionary containing:
- 'success': bool
- 'filepath': the file that was analysed
- 'words': word count
- 'lines': line count
- 'characters': character count
- 'error': error message (only if success is False)
"""
try:
abs_filepath = os.path.abspath(filepath)
norm = norm_path(abs_filepath)
self.report_start(f"Counting words in {norm}", end="")
if not os.path.isfile(abs_filepath):
self.report_error(f"Not a file: {norm}")
return {"success": False, "error": f"Not a file: {norm}",
"filepath": filepath}
with open(abs_filepath, "r", encoding="utf-8") as fh:
text = fh.read()
words = len(text.split())
lines = text.count("\n") + (1 if text and not text.endswith("\n") else 0)
chars = len(text)
self.report_result(f"{words} words, {lines} lines, {chars} chars")
return {
"success": True,
"filepath": filepath,
"words": words,
"lines": lines,
"characters": chars,
}
except Exception as e:
self.report_error(f"Error: {e}")
return {"success": False, "error": str(e), "filepath": filepath}
# ── CLI testing harness ──────────────────────────────────────────────
def main():
import argparse
parser = argparse.ArgumentParser(description="Word count tool")
parser.add_argument("filepath", help="File to analyse")
parser.add_argument("--json", "-j", action="store_true",
help="Output as JSON")
args = parser.parse_args()
result = WordCount().run(filepath=args.filepath)
if args.json:
print(json.dumps(result, indent=2))
else:
if result["success"]:
print(f"{result['words']} words, "
f"{result['lines']} lines, "
f"{result['characters']} characters")
else:
print(f"Error: {result['error']}")
if __name__ == "__main__":
main()
Step 3 — Verify discovery¶
from janito.tools import discover_toolsets
tools = discover_toolsets(["files"])
assert "WordCount" in tools, "Tool was not discovered!"
Or from the command line:
Step 4 — (Optional) Register a new toolset for auto-loading¶
If you created a new toolset directory, add its name to the
AUTOLOAD_TOOLSETS list in janito/tooling/tools_registry.py:
Toolsets not in this list can still be loaded at runtime via:
That is how gmail and onedrive work — they are loaded on demand.
Schema Generation (How the LLM Sees Your Tool)¶
get_function_schema() in tools_registry.py auto-generates an
OpenAI function-calling schema from the wrapper's metadata:
| Schema field | Source |
|---|---|
function.name |
The class name (e.g. WordCount) |
function.description |
First line of the class docstring |
parameters.properties.<p>.type |
Type hint on run() parameter: str→string, int→integer, float→number, bool→boolean, List[T]→array |
parameters.properties.<p>.description |
Parsed from the Args: block of the docstring on the wrapper (which is the class docstring, not the run() docstring) — see note below |
parameters.required |
Parameters that have no default value |
Optional[T] |
Unwrapped; the inner type is used and the parameter is not required (it has a default of None) |
Important note on parameter descriptions. The discovery wrapper copies
cls.__doc__(the class docstring) onto the callable.get_function_schema()parses theArgs:section of that docstring for per-parameter descriptions. Because the detailedArgs:block typically lives in therun()method's docstring, per-parameter descriptions are not propagated to the schema by default. The LLM still receives correct parameter names, types, and required flags. If you want per-parameter descriptions visible to the LLM, add anArgs:section to the class docstring as well.
Example generated schema¶
For the WordCount tool above the registry produces:
{
"type": "function",
"function": {
"name": "WordCount",
"description": "Tool for counting words, lines, and characters in a text file.",
"parameters": {
"type": "object",
"properties": {
"filepath": { "type": "string" }
},
"required": ["filepath"]
}
}
}
The Permission System¶
The permissions string on @tool(permissions="…") uses single-character
flags:
| Char | Meaning | Example tools |
|---|---|---|
r |
Read — reads files, directories, network, system info | ReadFile, ListFiles, GetUrl |
w |
Write — creates, modifies, or deletes files/dirs | CreateFile, DeleteFile, MoveFile |
x |
Execute — runs commands, scripts, programs | RunPythonCode, RunBashCode |
Flags can be combined: "rw", "rx", "rwx".
How permissions affect behaviour¶
- Privilege gating — when the user starts janito with
-r,-w,-xflags (janito/privileges.py), only tools whose permission characters are all satisfied are loaded. If no flags are passed, all tools load. - Colour-coded reporting —
report_start()picks an ANSI colour based on the permission string: green for read-only, yellow for write, red-ish for execute. - Introspection —
get_all_tool_permissions()/get_tool_permissions()expose permissions at runtime (used by the/toolsshell command).
Choose the least-privilege string that covers your tool's behaviour.
The should_load() Gate¶
Override the class method to perform runtime checks before the tool is registered:
@tool(permissions="x")
class RunBashCode(BaseTool):
@classmethod
def should_load(cls) -> bool:
shell = cls._find_shell()
if shell is None:
cls._load_skip_reason = "No Bash or POSIX shell found on this system"
return False
return True
Rules:
- Must be a
@classmethod. - Return
Trueto allow loading (default),Falseto skip. - Set
cls._load_skip_reasonto a human-readable string before returningFalse(shown by the/toolsdiagnostic command). - Must not raise; exceptions are caught and recorded as skip reasons.
- Common checks: binary on
PATH, platform / OS, environment variables, credentials in the secrets store.
Progress Reporting¶
There are two reporting APIs. Both write to stderr (never stdout, so they don't pollute the JSON result), but they serve different contexts.
When to use which¶
| Context | API | Import |
|---|---|---|
Inside a tool's run() method (you inherit BaseTool) |
self.report_*() instance methods |
Inherited — no import needed |
| Outside a tool class — MCP integrations, skills provider, CLI utilities, any helper code | report_*() standalone functions |
from janito.tooling.reporter import report_start, … or from janito.tooling import report_start, … |
Rule of thumb: if you are writing a BaseTool subclass, always use
self.report_*(). The standalone functions exist for code that cannot
inherit from BaseTool (it has no run() method, no permissions, etc.).
BaseTool instance methods (self.report_*)¶
These are permission-aware: report_start() automatically picks an ANSI
colour based on the tool's @tool(permissions="…") string, so the user can
see at a glance whether a tool is read-only (green), write (yellow), or
execute (yellow).
def report_start(self, message: str, end: str = "\n") -> None
def report_progress(self, message: str, end: str = "\n") -> None
def report_result(self, message: str, end: str = "\n") -> None
def report_error(self, message: str, end: str = "\n") -> None
def report_warning(self, message: str, end: str = "\n") -> None
| Method | Visual | Use for |
|---|---|---|
self.report_start(msg, end="\n") |
Colour by permissions: 🟢 r, 🟡 w/x, 🔵 none |
Announce the operation is beginning |
self.report_progress(msg, end="\n") |
Plain text (no colour) | Intermediate progress updates |
self.report_result(msg, end="\n") |
White + ✅ prefix | Final success summary |
self.report_error(msg, end="\n") |
❌ prefix | Errors |
self.report_warning(msg, end="\n") |
⚠️ prefix | Non-fatal warnings |
Standalone functions (janito.tooling.reporter)¶
For code outside BaseTool subclasses — e.g. mcp_manager.py,
skills_provider.py, CLI helpers. These are not permission-aware; instead
report_start accepts an explicit color parameter.
from janito.tooling.reporter import (
report_start, report_progress, report_result,
report_error, report_warning, report_info,
)
def report_start(message: str, end: str = "\n", color: str = Colors.CYAN) -> None
def report_progress(message: str, end: str = "\n") -> None
def report_result(message: str, end: str = "\n") -> None
def report_error(message: str, end: str = "\n") -> None
def report_warning(message: str, end: str = "\n") -> None
def report_info(message: str, end: str = "\n") -> None # ← only here, not on BaseTool
Key differences from the BaseTool methods:
BaseTool.report_start |
reporter.report_start |
|
|---|---|---|
| Colour | Auto from _tool_permissions |
Explicit color param (default CYAN) |
| Prefix | Leading space (to separate from LLM output) | Leading space + 🔄 emoji |
| Extra functions | — | report_info() (ℹ️ prefix, cyan) |
A Colors helper class is also available:
from janito.tooling.reporter import Colors
# Colors.CYAN, Colors.GREEN, Colors.YELLOW, Colors.RED, Colors.WHITE, Colors.RESET
Incremental progress lines¶
Both APIs support the end="" trick to build a single line step by step:
# Inside a BaseTool.run():
self.report_start(f"📖 Reading {norm}", end="") # no newline
self.report_progress(f" ({size} bytes)", end="") # appended to same line
self.report_result(f"Read {n} lines") # newline + ✅
# Outside a tool class:
report_start(f"Loading skill '{name}'", end="")
report_progress(" …", end="")
report_result("Done")
Toolset Organization and Discovery¶
Directory layout¶
janito/tools/<toolset_name>/
__init__.py # package marker (docstring-only is fine)
tool_a.py # one class per file (convention)
tool_b.py
…
Discovery rules (discover_toolsets)¶
- Each
.pyfile in the toolset directory (except__init__.py) is imported. - Every class defined in that module (checked via
__module__) that carries_is_tool = True(set by@tool) is a candidate. - Candidates pass through
should_load()and privilege checks. - Surviving classes are wrapped and registered under their class name.
Auto-loading vs. on-demand¶
- Auto-loaded toolsets are listed in
AUTOLOAD_TOOLSETSinjanito/tooling/tools_registry.py(currently["files", "system"]). They are discovered lazily on first registry access. - On-demand toolsets (e.g.
gmail,onedrive) are loaded by callingadd_toolset("gmail")at runtime.
Naming conventions¶
- File name:
snake_case.py(e.g.word_count.py) - Class name:
PascalCase(e.g.WordCount) — this becomes the tool name visible to the LLM - One tool class per file (strong convention in this codebase)
Return Value Contract¶
run() must always return a Dict[str, Any].
Required keys¶
| Key | Type | Description |
|---|---|---|
success |
bool |
True if the operation succeeded, False otherwise |
On success — include meaningful result data¶
On failure — include an error message¶
Guidelines¶
- Never raise from
run(). Catch all exceptions and return asuccess: Falsedict. The framework does not wrap yourrun()in a try/except. - Echo back input parameters so the LLM can correlate the result with the call.
- Keep values JSON-serialisable (strings, numbers, bools, lists, dicts).
- For large outputs consider truncation parameters (see
ReadFile.max_lines,GetUrl.max_length).
CLI Testing Harness¶
Every tool module should include a main() function so it can be tested
standalone:
# Direct module execution
python -m janito.tools.files.word_count myfile.txt
python -m janito.tools.files.word_count myfile.txt --json
# Or via the tool registry
python -c "
from janito.tools import discover_toolsets
tools = discover_toolsets(['files'])
result = tools['WordCount'](filepath='myfile.txt')
print(result)
"
The convention is:
def main():
import argparse
parser = argparse.ArgumentParser(description="…")
# … define arguments mirroring run() parameters …
parser.add_argument("--json", "-j", action="store_true",
help="Output in JSON format")
args = parser.parse_args()
result = MyTool().run(…)
if args.json:
print(json.dumps(result, indent=2))
else:
# human-friendly output
…
if __name__ == "__main__":
main()
Complete Worked Example¶
Below is the full, copy-paste-ready source for a WordCount tool placed at
janito/tools/files/word_count.py.
#!/usr/bin/env python3
"""
Word Count Tool - Counts words, lines, and characters in a file.
Note: This tool requires the progress reporting system from the tooling package.
For direct execution, use: python -m janito.tools.files.word_count [args]
For AI function calling, use through the tool registry (tooling.tools_registry).
"""
import os
import json
from typing import Dict, Any
from ...tooling import BaseTool, norm_path
from ..decorator import tool
@tool(permissions="r")
class WordCount(BaseTool):
"""
Tool for counting words, lines, and characters in a text file.
"""
def run(self, filepath: str) -> Dict[str, Any]:
"""
Count words, lines, and characters in a text file.
Args:
filepath (str): Path to the text file to analyse.
Returns:
Dict[str, Any]: A dictionary containing:
- 'success': bool
- 'filepath': the file that was analysed
- 'words': total word count
- 'lines': total line count
- 'characters': total character count
- 'error': error message (only present if success is False)
"""
try:
abs_filepath = os.path.abspath(filepath)
norm = norm_path(abs_filepath)
# 1. Report start (no newline yet — we append progress)
self.report_start(f"Counting words in {norm}", end="")
# 2. Validate input
if not os.path.isfile(abs_filepath):
self.report_error(f" Not a file: {norm}")
return {
"success": False,
"error": f"Not a file: {norm}",
"filepath": filepath,
}
# 3. Do the work
size = os.path.getsize(abs_filepath)
self.report_progress(f" ({size} bytes)", end="")
with open(abs_filepath, "r", encoding="utf-8") as fh:
text = fh.read()
words = len(text.split())
lines = text.count("\n") + (
1 if text and not text.endswith("\n") else 0
)
chars = len(text)
# 4. Report success
self.report_result(f"{words} words, {lines} lines, {chars} chars")
# 5. Return structured result
return {
"success": True,
"filepath": filepath,
"words": words,
"lines": lines,
"characters": chars,
}
except Exception as e:
self.report_error(f"Error: {e}")
return {
"success": False,
"error": str(e),
"filepath": filepath,
}
# ── CLI testing harness ──────────────────────────────────────────────────────
def main():
"""Command line interface for testing the WordCount tool."""
import argparse
parser = argparse.ArgumentParser(description="Word count tool")
parser.add_argument("filepath", help="Path to the text file")
parser.add_argument("--json", "-j", action="store_true",
help="Output in JSON format")
args = parser.parse_args()
result = WordCount().run(filepath=args.filepath)
if args.json:
print(json.dumps(result, indent=2))
else:
if result["success"]:
print(f" Words: {result['words']}")
print(f" Lines: {result['lines']}")
print(f" Characters: {result['characters']}")
else:
print(f"Error: {result['error']}")
if __name__ == "__main__":
main()
Checklist Before Submitting¶
- [ ] File placement — one tool class per
.pyfile inside ajanito/tools/<toolset>/directory. - [ ]
__init__.py— toolset directory has an__init__.py(even if it only contains a docstring). - [ ] Inherits
BaseTool—from ...tooling import BaseTool. - [ ] Decorated —
@tool(permissions="…")with the minimal permission string. - [ ] Class docstring — first line is a clear, concise description (this is what the LLM sees).
- [ ]
run()method — typed parameters,Dict[str, Any]return, Google-style docstring withArgs:andReturns:sections. - [ ] No exceptions escape
run()— wrap everything in try/except, return{"success": False, "error": "…"}. - [ ]
successkey — always present in the returned dict. - [ ] Progress reporting — use
report_start/report_progress/report_result/report_errorfor user-visible feedback. - [ ]
norm_path()— use for any paths shown to the user. - [ ]
should_load()— override if the tool has runtime dependencies (binaries, credentials, platform). Set_load_skip_reason. - [ ] CLI
main()— include an argparse-based test harness underif __name__ == "__main__". - [ ] Autoload / on-demand — if new toolset, decide whether to add it to
AUTOLOAD_TOOLSETSor leave it foradd_toolset(). - [ ] Test — run the module directly and verify discovery via
discover_toolsets().