# Build Your First Tool
{{< tier level="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.

{{< callout type="info" >}}
**Before you start:** you need a macOS or Linux workflow host with Node installed, plus a Valdr CLI that supports user tools (see [compatibility](/valdr/docs/getting-started/configure/#user-tools-compatibility)). Windows isn't supported for running tools.
{{< /callout >}}

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`:

```bash
mkdir -p my-tools/tools/text
```

## 1. Create the pack

Save `my-tools/pack.yaml`:

```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`:

```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:

```js
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:

```bash
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:

```json
{"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](/valdr/docs/workflows/user-tools/manifest/) reference when you adapt the example.

## Next step

[Package, import, and run your tool](/valdr/docs/workflows/user-tools/package-and-run/).

