Skip to content

Build Your First Tool

Sovereign

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.

Before you start: you need a macOS or Linux workflow host with Node installed, plus a Valdr CLI that supports user tools (see compatibility). Windows isn’t supported for running tools.

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/text

1. 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: tools

The 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: idempotent

3. 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"}
JSON

Expected stdout:

{"ok":true,"data":{"characters":28,"words":5,"lines":2}}

Then check the failure paths:

TryExpect
"text":""All three counts are 0
A numeric text, or an extra input propertyinvalid_input
An unknown actionunsupported_action
Malformed JSONinvalid_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.

Next step

Package, import, and run your tool.