Build Your First Tool
In this guide you build a working user tool from scratch: a small Node program that counts characters, words, and lines. It needs no dependencies, files, credentials, or network access, so you can focus on the pattern. By the end, a workflow step will run your code and pass its counts to the next step.
The same pattern works for any script you want to put into a workflow: describe the action, read one JSON request, write one JSON result.
You will create three files, test the program directly, then package and import it. Run the shell commands from the directory that will contain my-tools:
mkdir -p my-tools/tools/text1. Create the pack
Save my-tools/pack.yaml:
schemaVersion: 1.0
pack: my-tools
name: My Tools
version: 1.0.0
description: Custom tools for my workflows.
includes:
- path: toolsThe pack decides which folders go into the archive. One pack can hold many tools, alongside agents, prompts, and workflows.
2. Declare the tool
The manifest is the tool’s contract: its identity, the file it runs, its limits, and the exact shape of each action’s input and output. Save my-tools/tools/text/text.tool.yaml:
schemaVersion: 1
id: my-tools.user.text
revision: 1.0.0
icon: code-bracket
name: Text summary
description: "Count Unicode characters, ASCII-whitespace-separated words, and LF-separated lines without reading files or accessing the network."
files:
- path: main.mjs
process:
executable: node
args:
- main.mjs
inheritEnv: []
limits:
timeoutSeconds: 10
maxInputBytes: 65536
maxResultBytes: 4096
maxLogBytes: 4096
actions:
- id: summarize
path:
- text
- summarize
name: Summarize text
description: "Count Unicode code points, words separated by ASCII whitespace, and LF-separated lines. Empty text has zero lines."
inputSchema:
type: object
properties:
text:
type: string
required:
- text
additionalProperties: false
outputSchema:
type: object
properties:
characters:
type: integer
minimum: 0
words:
type: integer
minimum: 0
lines:
type: integer
minimum: 0
required:
- characters
- words
- lines
additionalProperties: false
execution: supported
sideEffects: read
retry: idempotent3. Implement the program
Save my-tools/tools/text/main.mjs. This is the complete program; no dependencies are needed:
import { readFileSync } from 'node:fs';
const fail = (code, message) => ({ ok: false, error: { code, message } });
const isObject = value => value !== null && typeof value === 'object' && !Array.isArray(value);
function run(request) {
if (!isObject(request) || request.protocolVersion !== 1 ||
request.toolId !== 'my-tools.user.text' || request.toolRevision !== '1.0.0' ||
typeof request.operationId !== 'string' || !request.operationId ||
typeof request.attemptId !== 'string' || !request.attemptId) {
return fail('invalid_request', 'Expected a version 1 request for my-tools.user.text 1.0.0.');
}
if (request.action !== 'summarize') {
return fail('unsupported_action', 'Unknown action.');
}
if (!isObject(request.input) || Object.keys(request.input).length !== 1 ||
typeof request.input.text !== 'string') {
return fail('invalid_input', 'Expected input {text: string}.');
}
const text = request.input.text;
return { ok: true, data: {
characters: [...text].length,
words: text.split(/[ \t\r\n\f\v]+/u).filter(Boolean).length,
lines: text === '' ? 0 : text.split('\n').length,
} };
}
let result;
try {
const source = new TextDecoder('utf-8', { fatal: true }).decode(readFileSync(0));
result = run(JSON.parse(source));
} catch {
result = fail('invalid_request', 'Expected one UTF-8 JSON request on stdin.');
}
process.stdout.write(JSON.stringify(result) + '\n');A few rules keep every tool predictable:
- Write exactly one JSON result to stdout. Send logs and diagnostics to stderr, and never print credentials.
- Exit zero for handled errors. A
{"ok":false,...}result with exit code zero tells Valdr the tool ran and reported a clean failure. - Check what you receive. Reject unknown actions and unexpected input with a clear error code.
characters counts Unicode code points, words splits on ASCII whitespace, and lines counts LF-separated segments. Empty text has zero lines.
4. Run it directly
Test the program before Valdr is involved. This command sends the same request shape Valdr uses; the IDs are placeholder test values:
node my-tools/tools/text/main.mjs <<'JSON'
{"protocolVersion":1,"toolId":"my-tools.user.text","toolRevision":"1.0.0","action":"summarize","input":{"text":"Hello Valdr\nTools are ready."},"operationId":"local-test-1","attemptId":"local-attempt-1"}
JSONExpected stdout:
{"ok":true,"data":{"characters":28,"words":5,"lines":2}}Then check the failure paths:
| Try | Expect |
|---|---|
"text":"" | All three counts are 0 |
A numeric text, or an extra input property | invalid_input |
An unknown action | unsupported_action |
| Malformed JSON | invalid_request, with no stack trace on stdout |
Your tool works. The next guide packages it, imports it into Valdr, and runs it as a workflow step. Use the Manifest and Schemas reference when you adapt the example.