@speakeasy-api/functions
v0.20.0
Published
Build MCP tools in TypeScript and deploy them with the Speakeasy CLI.
Maintainers
Keywords
Readme
Speakeasy Functions for TypeScript
Speakeasy Functions are small pieces of code that represent LLM tools. They are deployed to the Speakeasy AI Control Plane and are then exposed to LLMs via MCP servers.
This library provides a small framework for authoring Speakeasy Functions in TypeScript. The "Hello, World!" example is:
import { Functions } from "@speakeasy-api/functions";
import * as z from "zod/mini";
const functions = new Functions().tool({
name: "greet",
description: "Greet someone special",
inputSchema: { name: z.string() },
async execute(ctx, input) {
return ctx.json({ message: `Hello, ${input.name}!` });
},
});
export default functions;Quickstart
Install the Speakeasy AI Control Plane CLI with
brew install speakeasy-api/tap/cli or npm i -g @speakeasy-api/cli, then
scaffold, build and deploy a project:
speakeasy functions init my-tools
cd my-tools
speakeasy functions build
speakeasy functions pushspeakeasy functions dev runs the project's dev script, which starts a local
MCP server with the MCP Inspector. speakeasy functions build runs this
package from the project's node_modules, so the build always matches the SDK
version the project depends on.
npm create @gram-ai/function and the gf build and gf push commands still
work but are deprecated in favour of the speakeasy functions commands.
Installation
Use one of the following commands to add the package to your project:
pnpm add @speakeasy-api/functions
## Or one of the following:
# bun add @speakeasy-api/functions
# npm add @speakeasy-api/functionsMigrating from @gram-ai/functions
This package was published as @gram-ai/functions. The old names are
deprecated aliases and keep working, so you can migrate when it suits you:
| Old name | New name |
| --------------------------------------- | ------------------------------------------- |
| @gram-ai/functions (and its subpaths) | @speakeasy-api/functions |
| Gram | Functions |
| fromGram (/mcp) | fromFunctions |
| withGram (/mcp) | withFunctions |
| gram.config.{ts,mts,js,mjs} | speakeasy.config.{ts,mts,js,mjs} |
| src/gram.ts (default entrypoint) | src/functions.ts |
| gram.deploy.json | speakeasy.deploy.json |
| dist/gram.zip | dist/functions.zip |
| GRAM_USER_EMAIL | SPEAKEASY_AI_USER_EMAIL (both are set) |
| GRAM_CLI_PATH, GRAM_DEV | SPEAKEASY_AI_CLI_PATH, SPEAKEASY_AI_DEV |
| GRAM_* CLI environment variables | SPEAKEASY_AI_* |
@gram-ai/functions now re-exports this package at the same version, so
import { Gram } from "@gram-ai/functions" and the gf command work as
before. When a project has both a new and an old file name, the new one wins.
The build prints a one-line note when it uses gram.config.* or
gram.deploy.json.
Core Concepts
The Functions Instance
The Functions class is the main entry point for defining tools. You create an instance and chain .tool() calls to register multiple tools:
import { Functions } from "@speakeasy-api/functions";
import * as z from "zod/mini";
const functions = new Functions()
.tool({
name: "add",
description: "Add two numbers",
inputSchema: { a: z.number(), b: z.number() },
async execute(ctx, input) {
return ctx.json({ sum: input.a + input.b });
},
})
.tool({
name: "multiply",
description: "Multiply two numbers",
inputSchema: { a: z.number(), b: z.number() },
async execute(ctx, input) {
return ctx.json({ product: input.a * input.b });
},
});
export default functions;Composing Functions Instances
You can compose multiple Functions instances together using the extend() method,
similar to Hono's route groups pattern. This is useful for organizing tools by
domain or functionality:
import { Functions } from "@speakeasy-api/functions";
import * as z from "zod/mini";
// Math tools
const mathTools = new Functions()
.tool({
name: "add",
description: "Add two numbers",
inputSchema: { a: z.number(), b: z.number() },
async execute(ctx, input) {
return ctx.json({ sum: input.a + input.b });
},
})
.tool({
name: "multiply",
description: "Multiply two numbers",
inputSchema: { a: z.number(), b: z.number() },
async execute(ctx, input) {
return ctx.json({ product: input.a * input.b });
},
});
// String tools
const stringTools = new Functions().tool({
name: "uppercase",
description: "Convert string to uppercase",
inputSchema: { text: z.string() },
async execute(ctx, input) {
return ctx.text(input.text.toUpperCase());
},
});
// Combine both
const functions = mathTools.extend(stringTools);
export default functions;The extend() method:
- Merges tools: All tools from both instances are combined
- Override behavior: If tool names collide, the extended instance's tools override the original's
- Preserves context: Each tool maintains its original
Functionsinstance's execution context (environment variables and lax validation settings) - Mutates original: Modifies and returns the original instance (not a copy)
Tool Definition
Each tool requires:
- name: A unique identifier for the tool
- description (optional): Human-readable description of what the tool does
- inputSchema: A Zod schema object defining the expected input parameters
- execute: An async function that implements the tool logic
Tool Context
The execute function receives a ctx (context) object with helper methods:
ctx.json(data)
Returns a JSON response:
async execute(ctx, input) {
return ctx.json({ result: "success", value: 42 });
}ctx.text(data)
Returns a plain text response:
async execute(ctx, input) {
return ctx.text("Operation completed successfully");
}ctx.markdown(data)
Returns a markdown response:
async execute(ctx, input) {
return ctx.markdown("# Heading");
}ctx.html(data)
Returns an HTML response:
async execute(ctx, input) {
return ctx.html("<h1>Hello, World!</h1>");
}ctx.fail(data, options?)
Throws an error response (never returns):
async execute(ctx, input) {
if (!input.value) {
ctx.fail({ error: "value is required" }, { status: 400 });
}
// ...
}ctx.signal
An AbortSignal for handling cancellation:
async execute(ctx, input) {
const response = await fetch(input.url, { signal: ctx.signal });
return ctx.json(await response.json());
}ctx.env
Access to parsed environment variables defined by the Functions instance:
const functions = new Functions({
envSchema: {
BASE_URL: z.string().transform((url) => new URL(url)),
},
}).tool({
name: "api_call",
inputSchema: { endpoint: z.string() },
async execute(ctx, input) {
const baseURL = ctx.env.BASE_URL;
// Use baseURL...
},
});Input Validation
Input schemas are defined using Zod:
import { Functions } from "@speakeasy-api/functions";
import * as z from "zod/mini";
const functions = new Functions().tool({
name: "create_user",
inputSchema: {
email: z.string().check(z.email()),
age: z.number().check(z.min(18)),
name: z.optional(z.string()),
},
async execute(ctx, input) {
// input is fully typed based on the schema
return ctx.json({ userId: "123" });
},
});Lax Mode
By default, the framework strictly validates input. You can enable lax mode to allow unvalidated input to pass through:
const functions = new Functions({ lax: true });Environment Variables
Defining Variables
Environment variables that are used by tools must be defined when instantiating
the Functions class. This is done using a Zod v4 object schema:
import { Functions } from "@speakeasy-api/functions";
import * as z from "zod/mini";
const functions = new Functions({
envSchema: {
API_KEY: z.string().describe("API key for external service"),
BASE_URL: z.string().check(z.url()).describe("Base URL for API requests"),
},
});Whenever a tool wants to access a new environment variable, a definition must be
added to the envSchema if one does not exist. When this function is
deployed, end users will then be able to provide values for these variables when
installing the corresponding MCP servers.
Runtime Environment
Environment variables are read from process.env by default, but you can
override them when creating the Functions instance. This can be useful for testing
or local development. Example:
import { Functions } from "@speakeasy-api/functions";
import * as z from "zod/mini";
const functions = new Functions({
env: {
API_KEY: "secret-key",
BASE_URL: "https://api.example.com",
},
envSchema: {
API_KEY: z.string().describe("API key for external service"),
BASE_URL: z.string().check(z.url()).describe("Base URL for API requests"),
},
});If not provided, the framework falls back to process.env.
Authentication & Identity
OAuth Tokens
If your function needs to access external APIs on behalf of the user, you can
declare an OAuth variable in authInput. Speakeasy will handle the OAuth flow and
inject the acquired token into the specified environment variable:
const functions = new Functions({
envSchema: {
OAUTH_TOKEN: z.optional(z.string()),
},
authInput: {
oauthVariable: "OAUTH_TOKEN",
},
});User Identity
When an authenticated user invokes a tool, you can opt in to receiving
their email address by setting gramEmail: true in authInput. The email will
be available as the SPEAKEASY_AI_USER_EMAIL environment variable. The deprecated
GRAM_USER_EMAIL variable carries the same value, so existing code keeps
working:
const functions = new Functions({
envSchema: {
OAUTH_TOKEN: z.optional(z.string()),
SPEAKEASY_AI_USER_EMAIL: z.optional(z.string()),
},
authInput: {
oauthVariable: "OAUTH_TOKEN",
gramEmail: true,
},
}).tool({
name: "whoami",
description: "Returns the current user's email",
inputSchema: {},
async execute(ctx) {
const email = ctx.env.SPEAKEASY_AI_USER_EMAIL;
if (!email) {
return ctx.json({ authenticated: false });
}
return ctx.json({ authenticated: true, email });
},
});SPEAKEASY_AI_USER_EMAIL will be empty when the request is unauthenticated.
Response Types
The framework supports multiple response types. All response methods return Web API Response objects.
JSON Response
return ctx.json({
status: "success",
data: { id: 123, name: "Example" },
});Text Response
return ctx.text("Plain text response");HTML Response
return ctx.html(`
<!DOCTYPE html>
<html>
<body><h1>Hello</h1></body>
</html>
`);Custom Response
You can also return a plain Response object:
return new Response(data, {
status: 200,
headers: {
"Content-Type": "application/xml",
"X-Custom-Header": "value",
},
});Error Handling
Using ctx.fail()
Use ctx.fail() to throw error responses:
async execute(ctx, input) {
if (!input.userId) {
ctx.fail(
{ error: "userId is required" },
{ status: 400 }
);
}
const user = await fetchUser(input.userId);
if (!user) {
ctx.fail(
{ error: "User not found" },
{ status: 404 }
);
}
return ctx.json({ user });
}The response body is exactly the data you pass — no stack trace is added, since a deployed function is a minified bundle whose frames mean nothing to the tool's caller and cost an MCP client context on every turn that follows.
A tool call that fails input validation is reported the same way: error names
each offending input and what was wrong with it on one line, alongside the
structured issues from Zod.
{
"error": "org_id: Invalid input: expected string, received undefined",
"issues": [
{
"expected": "string",
"code": "invalid_type",
"path": ["org_id"],
"message": "Invalid input: expected string, received undefined"
}
]
}Using assert()
The assert function provides a convenient way to validate conditions and throw error responses:
import { assert } from "@speakeasy-api/functions";
async execute(ctx, input) {
assert(input.userId, { error: "userId is required" }, { status: 400 });
const user = await fetchUser(input.userId);
assert(user, { error: "User not found" }, { status: 404 });
return ctx.json({ user });
}The assert function throws a Response object when the condition is false. The framework catches all thrown values, and if any happen to be a Response instance, they will be returned to the client.
Key points about assert:
- First parameter is the condition to check
- Second parameter is the error data (must include an
errorfield) - Third parameter is optional and can specify the status code (defaults to 500)
- Serializes only the data you pass; no stack trace is added
- Uses TypeScript's assertion type to narrow types when the assertion passes
Manifest Generation
Generate a manifest of all registered tools:
import { Functions } from "@speakeasy-api/functions";
const functions = new Functions().tool({/* ... */}).tool({/* ... */});
const manifest = functions.manifest();
// {
// version: "0.0.0",
// tools: [
// {
// name: "tool1",
// description: "...",
// inputSchema: "...", // JSON Schema string
// variables: { ... }
// },
// ...
// ]
// }Deploying
Deploy with speakeasy functions push. It builds the project, adds the zip
file to the deployment file (speakeasy.deploy.json by default, or
gram.deploy.json when only that file exists) and pushes a deployment. The
target project is --project (or SPEAKEASY_AI_PROJECT), then deployProject
in speakeasy.config.ts, then the project you chose with speakeasy auth.
The slug comes from --slug, then slug in speakeasy.config.ts, then the
package.json name without its scope. Pass --no-build to deploy the
existing build output.
Project config
The build reads the first speakeasy.config.{ts,mts,js,mjs} file in the
project directory:
import { defineConfig } from "@speakeasy-api/functions/build";
export default defineConfig({
entrypoint: "src/functions.ts",
slug: "my-tools",
});Without an entrypoint, the build uses src/functions.ts, or src/gram.ts
when only that file exists. The build writes dist/functions.zip.
Building programmatically
@speakeasy-api/functions/build exports the build that
speakeasy functions build runs:
import { build, resolveProject } from "@speakeasy-api/functions/build";
// Builds dist/functions.zip and returns the resolved project settings.
const { project, files } = await build({ cwd: "./my-tools" });
// Resolves the same settings without building.
const settings = await resolveProject({ cwd: "./my-tools", outDir: "out" });Both accept cwd, configFile, entrypoint and outDir; the last three
override speakeasy.config.ts.
Deploying with gf
The deprecated gf build and gf push commands ship with
@gram-ai/functions. gf push runs the Speakeasy AI Control Plane CLI. It
picks the CLI in this order:
- The path in the
SPEAKEASY_AI_CLI_PATH(or deprecatedGRAM_CLI_PATH) environment variable. cli/bin/gramfrom this repository, whenSPEAKEASY_AI_DEV(or deprecatedGRAM_DEV) is1ortrue.speakeasyon yourPATH, ifspeakeasy --control-plane-cliconfirms it is the AI Control Plane CLI. The Speakeasy SDK generator CLI also installs aspeakeasybinary, and the SDK skips it.- The legacy
gramcommand.
Handling Tool Calls
Exporting the Functions instance from your module as the default export will allow
Speakeasy to handle tool calls automatically when deployed:
import { Functions } from "@speakeasy-api/functions";
const functions = new Functions().tool({/* ... */}).tool({/* ... */});
export default functions;You can also call tools programmatically:
const response = await functions.handleToolCall({
name: "add",
input: { a: 5, b: 3 },
});
const data = await response.json();
console.log(data); // { sum: 8 }With abort signal support:
const signal = AbortSignal.timeout(5000);
const response = await functions.handleToolCall(
{ name: "longRunning", input: {} },
{ signal },
);Type Safety
The framework provides full TypeScript type inference:
import { Functions } from "@speakeasy-api/functions";
import * as z from "zod/mini";
const functions = new Functions().tool({
name: "greet",
inputSchema: { name: z.string() },
async execute(ctx, input) {
// input.name is typed as string
return ctx.json({ message: `Hello, ${input.name}` });
},
});
// Type-safe tool calls
const response = await functions.handleToolCall({
name: "greet", // Only "greet" is valid
input: { name: "World" }, // input is typed correctly
});
// Response type is inferred
const data = await response.json(); // { message: string }