samai-sdk
Core concepts

Tools & schemas

defineTool() gives you full type inference from a schema — zod, or any Standard Schema V1 validator like valibot — with no manual typing or casts.

Defining a tool

A tool is a name, a description the model reads to decide when to call it, a schema describing its arguments, and an execute() function. Arguments are validated against the schema before execute() ever runs — an invalid call comes back as an isError tool result, not a crash.

tool-zod.ts
import { defineTool } from "samai-sdk";
import { z } from "zod";

const getWeather = defineTool({
  name: "get_weather",
  description: "Get the current weather for a city",
  parameters: z.object({ city: z.string() }),
  // args is inferred as { city: string } — no manual typing
  execute: async ({ city }) => `18°C and cloudy in ${city}`,
});

Standard Schema support (valibot, and others)

parameters also accepts any Standard Schema V1 validator — valibot 0.31+/1.x is fully supported, including JSON Schema generation for the model-facing tool definition. Behavior is identical to the zod path in every other respect:

tool-valibot.ts
import { defineTool } from "samai-sdk";
import * as v from "valibot";

const getWeather = defineTool({
  name: "get_weather",
  description: "Get the current weather for a city",
  parameters: v.object({
    city: v.pipe(v.string(), v.minLength(1)),
    units: v.optional(v.picklist(["metric", "imperial"]), "metric"),
  }),
  // args is inferred from valibot's own output type, same as the zod path
  execute: async ({ city, units }) =>
    `18${units === "imperial" ? "F" : "C"} and cloudy in ${city}`,
});

This works the same way across all 8 provider adapters — each one's tool-conversion step resolves valibot's JSON Schema via the optional @valibot/to-json-schema peer dependency (npm install @valibot/to-json-schema).

Also Standard Schema-aware

generateObject(), streamObject(), createSchemaGuardrail(), and Agent.outputSchema all accept the same zod-or-Standard-Schema input. zod behavior is completely unchanged everywhere — this is purely additive.

Requiring approval before execution

For a tool with real side effects — writing outside /tmp, sending an email, an MCP tool that mutates something — gate it behind human sign-off instead of letting it fire automatically:

approval.ts
const writeFile = defineTool({
  name: "write_file",
  description: "Write content to a file outside /tmp",
  parameters: z.object({ path: z.string(), content: z.string() }),
  execute: async ({ path, content }) => { /* ... */ },
  // require sign-off, always — or pass a predicate for conditional approval
  requiresApproval: true,
});

const result = await runAgent(client, agent, input, {
  onApprovalRequest: async ({ toolName, args }) => {
    // show a confirm dialog, check an allowlist, whatever your app needs
    return userConfirmedInUI;
  },
});

If a call requires approval and no onApprovalRequest handler is supplied, the call is rejected by default — fail closed, not silently executed.

createMCPClient() connects to any MCP server and exposes its tools as ordinary ToolDefinitions — mix them into an agent's tools array alongside locally-defined ones. createWebSearchTool() gives the model a real web_search tool backed by the Tavily or Brave search API — an actual HTTP request, not a stub.