Building a Voice

Create and publish your own Tutti voice package

A voice is an npm package that exports a class implementing the Voice interface. This guide walks through building one from scratch.

Project structure

my-voice/
├── src/
│   ├── index.ts          # Voice class + tool exports
│   └── tools/
│       ├── my-tool.ts    # Individual tool
│       └── other-tool.ts
├── tests/
│   └── my-voice.test.ts
├── package.json
├── tsconfig.json
└── tsup.config.ts

Step 1: Define a tool

Each tool has a name, description, Zod parameter schema, and an async execute function.

import { z } from "zod";
import type { Tool } from "@tuttiai/types";

const parameters = z.object({
  city: z.string().describe("City name"),
  units: z.enum(["celsius", "fahrenheit"]).default("celsius"),
});

export const weatherTool: Tool<z.infer<typeof parameters>> = {
  name: "get_weather",
  description: "Get the current weather for a city",
  parameters,
  execute: async (input) => {
    const response = await fetch(
      `https://api.weather.example/v1?city=${encodeURIComponent(input.city)}&units=${input.units}`,
    );
    const data = await response.json();
    return {
      content: `Weather in ${input.city}: ${data.temp}° ${input.units}, ${data.condition}`,
    };
  },
};

:::tip Return errors as { content: "Error message", is_error: true } instead of throwing. This sends the error back to the LLM so it can try a different approach. :::

Step 2: Create the voice class

import type { Permission, Voice } from "@tuttiai/types";
import { weatherTool } from "./tools/weather.js";

export class WeatherVoice implements Voice {
  name = "weather";
  description = "Get weather data for any city";
  required_permissions: Permission[] = ["network"];
  tools = [weatherTool];
}

export { weatherTool };

Step 3: Configure the package

{
  "name": "@yourscope/weather-voice",
  "version": "0.1.0",
  "type": "module",
  "exports": {
    ".": {
      "import": "./dist/index.js",
      "types": "./dist/index.d.ts"
    }
  },
  "dependencies": {
    "@tuttiai/types": "*",
    "zod": "3.25.76"
  }
}
import { defineConfig } from "tsup";

export default defineConfig({
  entry: ["src/index.ts"],
  format: ["esm"],
  dts: true,
  clean: true,
});

Step 4: Write tests

import { describe, it, expect } from "vitest";
import { WeatherVoice } from "../src/index.js";

describe("WeatherVoice", () => {
  it("implements Voice with the correct name and permissions", () => {
    const voice = new WeatherVoice();
    expect(voice.name).toBe("weather");
    expect(voice.required_permissions).toEqual(["network"]);
    expect(voice.tools.length).toBeGreaterThan(0);
  });
});

Step 5: Use it

import { AnthropicProvider, defineScore } from "@tuttiai/core";
import { WeatherVoice } from "@yourscope/weather-voice";

export default defineScore({
  provider: new AnthropicProvider(),
  agents: {
    assistant: {
      name: "Weather Assistant",
      system_prompt: "You help users check the weather.",
      voices: [new WeatherVoice()],
      permissions: ["network"],
    },
  },
});

Step 6: Publish

npm publish --access public

Once published, anyone can install it:

npx tutti-ai add @yourscope/weather-voice

Voice lifecycle hooks

Voices can optionally implement setup() and teardown():

export class BrowserVoice implements Voice {
  name = "browser";
  required_permissions: Permission[] = ["browser"];
  tools = [/* ... */];

  async setup(context: VoiceContext) {
    // Called before first use — launch browser, connect to DB, etc.
  }

  async teardown() {
    // Called on cleanup — close browser, disconnect, etc.
  }
}

Edit this page on GitHub →