Developers

Adding tools

Define a schema-validated tool, register it, and it becomes available to the UI, CLI, agents, and plugins at once.

A tool is a typed unit of behavior callable by the renderer, CLI, agents, plugins, and internal code through the same ToolRegistry. Built-in tools live in packages/desktop/src/main/tools/.

Every tool should be small, schema-validated, honest about its capabilities, and implemented through a service rather than reaching across app boundaries.

1. Pick the owning service

Tools should expose behavior from a main-process service. Prefer adding logic to the service that owns the domain (for example BrowserTabService for browser tools, WorkspaceFileService for file tools), then add a thin tool wrapper. If the feature needs a new durable domain, add a service and wire it in bootstrap.ts first.

2. Define the tool

Use defineTool from @meith/protocol and a Zod input schema. Tool names are snake_case.

ts
import { defineTool } from "@meith/protocol"
import { ToolError, okResult } from "@meith/shared"
import { z } from "zod"
import type { ToolDeps } from "./deps.js"

export function createExampleTools(deps: ToolDeps) {
  return [
    defineTool({
      name: "example_echo",
      description: "Return the supplied message.",
      capabilities: ["read-only"],
      inputSchema: z.object({
        message: z.string().min(1).describe("Message to echo."),
      }),
      execute: async (ctx, input) => {
        if (ctx.signal?.aborted) {
          throw new ToolError("CANCELLED", "Call was cancelled.")
        }
        ctx.emit?.({ kind: "progress", message: "echoing", fraction: 1 })
        return okResult({ message: input.message })
      },
    }),
  ]
}

3. Register the tool

Register the tool factory in packages/desktop/src/main/bootstrap.ts:

ts
import { createExampleTools } from "./tools/exampleTools.js"

// after deps are available
registry.registerAll(createExampleTools(deps))

Once registered, the tool is available through:

  • renderer IPC via bridge.tools.call("example_echo", args),
  • CLI via meith call example_echo --message hello,
  • agents through the generated live tool catalog,
  • plugins through window.meithPlugin.tools.call() if granted the required capabilities,
  • the debug tool runner.

4. Declare capabilities correctly

Capabilities drive permission decisions and plugin grants. Be conservative and declare every meaningful effect — writes-files, controls-browser, starts-process, accesses-network, and destructive. Renderer and internal callers are trusted in-process but still audited; CLI, agent, and plugin callers need explicit grants for privileged capabilities.

5. Handle errors and cancellation

  • Reject invalid input with the Zod schema where possible.
  • Throw ToolError("VALIDATION_ERROR", message) for domain validation, and ToolError("PERMISSION_DENIED", ...) for ownership failures.
  • Long-running tools should observe ctx.signal and use ctx.emit for progress, log, partial_text, and artifact events.

6. Add a friendly CLI command when useful

Any tool is already reachable with meith call. For common workflows, add a command mapping in packages/cli/src/commands.ts:

ts
export const commands = {
  "example-echo": {
    tool: "example_echo",
    positionals: ["message"],
    summary: "Echo <message>",
  },
}

Checklist

  1. The service owns the actual behavior.
  2. The tool has a Zod input schema with useful descriptions.
  3. The tool name is snake_case and capabilities cover every side effect.
  4. Errors map to the right ToolErrorCode; long work observes ctx.signal and streams with ctx.emit.
  5. The tool is registered in bootstrap.ts, and tests cover the service and registry behavior.

Verify your change

Run pnpm --filter @meith/desktop test and pnpm typecheck at a minimum. For broad or shared changes, run pnpm check.