Custom tools let you extend agents with project-specific capabilities. Each tool is a script (bash, Python, TypeScript, or JavaScript) that receives JSON arguments on stdin and writes results to stdout. Tools run in a sandboxed environment.
Create a folder at {projectRoot}/.clawd/tools/{tool-id}/ with two files:
tool.json — Tool metadata:
{
"name": "check-deps",
"description": "Check for outdated npm dependencies",
"parameters": {
"path": {
"type": "string",
"description": "Path to package.json"
}
},
"required": ["path"],
"entrypoint": "check.sh",
"interpreter": "bash",
"timeout": 30
}
check.sh — Tool script:
#!/bin/bash
ARGS=$(cat)
PKG_PATH=$(echo "$ARGS" | jq -r '.path')
cd "$(dirname "$PKG_PATH")" && bun outdated 2>&1 || echo "No outdated deps"
Agents in this project automatically discover the tool as ct_check-deps on their next session.
Ask any agent in a channel:
“Create a custom tool called
lint-sqlthat takes afileparameter and runs sqlfluff lint on it.”
The agent uses the built-in custom_script management tool to create the folder, tool.json, and script for you.
| Field | Type | Required | Default | Description |
|---|---|---|---|---|
name |
string | Yes | — | Display name (lowercase, alphanumeric, hyphens, underscores, max 64 chars) |
description |
string | Yes | — | What the tool does (shown to agents in tool list) |
parameters |
object | No | {} |
Parameter definitions (see below) |
required |
string[] | No | [] |
Required parameter names |
entrypoint |
string | Yes | — | Script filename (no path separators) |
interpreter |
string | No | auto-detect | bash, sh, python3, python, bun, or node |
timeout |
number | No | 30 |
Execution timeout in seconds (1–300) |
Each key in parameters maps to a parameter object:
{
"parameters": {
"query": {
"type": "string",
"description": "SQL query to validate"
},
"format": {
"type": "string",
"description": "Output format",
"enum": ["json", "text", "csv"]
},
"verbose": {
"type": "boolean",
"description": "Enable verbose output"
}
}
}
Supported types: string, number, boolean, array, object.
ct_<tool-id> with argumentsBash:
#!/bin/bash
ARGS=$(cat)
NAME=$(echo "$ARGS" | jq -r '.name')
COUNT=$(echo "$ARGS" | jq -r '.count // 10')
echo "Hello $NAME, count=$COUNT"
Python:
#!/usr/bin/env python3
import json, sys
args = json.load(sys.stdin)
name = args.get("name", "world")
print(f"Hello {name}")
TypeScript (Bun):
const args = await Bun.stdin.json();
console.log(`Hello ${args.name}`);
JavaScript (Node/Bun):
const chunks = [];
for await (const chunk of process.stdin) chunks.push(chunk);
const args = JSON.parse(Buffer.concat(chunks).toString());
console.log(`Hello ${args.name}`);
| Stream | Limit | Behavior when exceeded |
|---|---|---|
| stdout | 64 KB | Truncated with [OUTPUT TRUNCATED...] marker |
| stderr | 16 KB | Truncated with [OUTPUT TRUNCATED...] marker |
Keep tool output concise. If your tool produces large output, summarize or filter it in the script before printing.
Custom tools always run sandboxed:
| Access | Scope |
|---|---|
| Read/Write | Project root (excluding .clawd/), /tmp |
| Read-only | .clawd/skills/, .clawd/tools/, /usr, /bin, /lib, /etc |
| Blocked | Home directory, .clawd/ config files, network (unless project allows) |
custom_scriptAgents have a built-in custom_script tool with 6 modes:
| Mode | Description |
|---|---|
list |
Show all custom tools in the project |
add |
Create a new tool (tool.json + script) |
edit |
Update an existing tool’s metadata or script |
delete |
Remove a tool and its directory |
view |
Display tool metadata and source code |
execute |
Run a tool directly (used internally) |
/^[a-z0-9][a-z0-9_-]{0,63}$//, \)bash, sh, python3, python, bun, nodeIf interpreter is omitted from tool.json, it is inferred from the entrypoint file extension:
| Extension | Interpreter |
|---|---|
.sh |
bash |
.py |
python3 |
.ts |
bun |
.js |
bun |
.clawd/tools/api-health/
├── tool.json
└── check.sh
tool.json:
{
"name": "api-health",
"description": "Check if an API endpoint is responding",
"parameters": {
"url": { "type": "string", "description": "API endpoint URL" },
"method": { "type": "string", "description": "HTTP method", "enum": ["GET", "POST", "HEAD"] }
},
"required": ["url"],
"entrypoint": "check.sh",
"timeout": 15
}
check.sh:
#!/bin/bash
ARGS=$(cat)
URL=$(echo "$ARGS" | jq -r '.url')
METHOD=$(echo "$ARGS" | jq -r '.method // "GET"')
HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" -X "$METHOD" "$URL" --max-time 10)
echo "Status: $HTTP_CODE for $METHOD $URL"
[ "$HTTP_CODE" -ge 200 ] && [ "$HTTP_CODE" -lt 400 ] && echo "OK" || echo "FAILED"
.clawd/tools/query-db/
├── tool.json
└── query.py
tool.json:
{
"name": "query-db",
"description": "Run a read-only SQL query against the dev database",
"parameters": {
"sql": { "type": "string", "description": "SQL SELECT query to execute" },
"limit": { "type": "number", "description": "Max rows to return" }
},
"required": ["sql"],
"entrypoint": "query.py",
"interpreter": "python3",
"timeout": 30
}
query.py:
#!/usr/bin/env python3
import json, sys, sqlite3
args = json.load(sys.stdin)
sql = args["sql"].strip()
limit = args.get("limit", 50)
if not sql.upper().startswith("SELECT"):
print("Error: Only SELECT queries allowed", file=sys.stderr)
sys.exit(1)
conn = sqlite3.connect("dev.db")
conn.row_factory = sqlite3.Row
rows = conn.execute(f"{sql} LIMIT {limit}").fetchall()
print(json.dumps([dict(r) for r in rows], indent=2))