Skip to main content
NAVAI enables AI agents to execute functions in your application through voice commands. Functions can run on the frontend (with access to UI state) or backend (with access to server resources).

How function execution works

Function execution follows a structured flow from voice command to execution and response.
1

User requests action

The user says something like “send a message to John” or “get my latest orders”.
2

AI agent calls tool

The AI calls execute_app_function (or a direct function tool) with the function name and parameters.
3

NAVAI resolves function

NAVAI checks if the function exists in the frontend registry. If not, it checks backend functions.
4

Function executes

The function runs with the provided payload and context, performing the requested action.
5

Result returns to AI

The function result is sent back to OpenAI, which may speak it to the user or use it to inform the next action.

Function definition structure

Functions are defined with a consistent structure across frontend and backend:
// From packages/voice-frontend/src/functions.ts:7-12
export type NavaiFunctionDefinition = {
  name: string;
  description: string;
  source: string;
  run: (payload: NavaiFunctionPayload, context: NavaiFunctionContext) => Promise<unknown> | unknown;
};
name
string
required
Unique identifier for the function. Used by the AI agent to call it. Automatically normalized to lowercase with underscores.
description
string
required
Human-readable description of what the function does. Helps the AI understand when to call it.
source
string
required
File path or identifier indicating where this function comes from. Used for debugging and logging.
run
function
required
The actual function implementation. Receives a payload object and context, returns a result or Promise.

Frontend vs backend functions

Functions can execute in two locations, each with different capabilities:
Execute in the client browser with access to:
  • UI state and context: Read/write React state, access local storage
  • Navigation: Use the context.navigate() function to change routes
  • Client-side APIs: Make direct API calls from the browser
  • User interaction: Show modals, update UI, trigger animations
Context type:
// From packages/voice-frontend/src/functions.ts:3-5
export type NavaiFunctionContext = {
  navigate: (path: string) => void;
};
Best for: UI manipulation, client-side state changes, navigation actions
Frontend functions take priority over backend functions with the same name. If you define a function in both locations, only the frontend version will be called.

Defining functions

NAVAI automatically discovers and loads functions from your codebase using conventions.

Simple function export

Export a function directly from a module:
// src/ai/functions/sendMessage.ts
export async function sendMessage(payload: { to: string; message: string }) {
  const { to, message } = payload;
  
  // Call your messaging API
  await messagingService.send({
    recipient: to,
    text: message
  });
  
  return {
    success: true,
    message: `Message sent to ${to}`
  };
}
How it’s registered:
  • Name: send_message (auto-normalized from sendMessage)
  • Description: Auto-generated (“Call exported function sendMessage”)
  • Source: src/ai/functions/sendMessage.ts#sendMessage

Function with arguments

Functions receive arguments through the payload.args array:
// src/ai/functions/getOrders.ts
export async function getOrders(userId: string, limit: number = 10) {
  const orders = await database.orders
    .where('userId', userId)
    .limit(limit)
    .orderBy('createdAt', 'desc');
  
  return {
    count: orders.length,
    orders: orders.map(o => ({
      id: o.id,
      total: o.total,
      date: o.createdAt
    }))
  };
}
The AI calls this with:
{
  "function_name": "get_orders",
  "payload": {
    "args": ["user123", 5]
  }
}

Function with context

Access the context by adding it as the last parameter:
// Frontend function with navigation
export function openUserProfile(userId: string, context: NavaiFunctionContext) {
  // Navigate to the user's profile
  context.navigate(`/users/${userId}`);
  
  return {
    success: true,
    message: `Opened profile for user ${userId}`
  };
}
// Backend function with request context
export async function getCurrentUser(payload: any, context: NavaiFunctionContext) {
  const req = context.req as Request;
  const userId = req.session?.userId;
  
  if (!userId) {
    throw new Error('Not authenticated');
  }
  
  const user = await database.users.findById(userId);
  return {
    id: user.id,
    name: user.name,
    email: user.email
  };
}

Class-based functions

Export a class to expose all its methods as functions:
// src/ai/functions/OrderManager.ts
export class OrderManager {
  async getOrders(userId: string) {
    // Implementation
    return await fetchOrders(userId);
  }
  
  async cancelOrder(orderId: string) {
    // Implementation
    return await cancelOrder(orderId);
  }
  
  async trackOrder(orderId: string) {
    // Implementation
    return await getOrderStatus(orderId);
  }
}
How it’s registered:
  • Three functions: order_manager_get_orders, order_manager_cancel_order, order_manager_track_order
  • Descriptions: Auto-generated (“Call class method OrderManager.getOrders()”)
  • Source: src/ai/functions/OrderManager.ts#OrderManager.methodName
Calling with constructor arguments:
{
  "function_name": "order_manager_get_orders",
  "payload": {
    "constructorArgs": [],
    "methodArgs": ["user123"]
  }
}

Object exports

Export an object with multiple functions:
// src/ai/functions/notifications.ts
export const notifications = {
  async send(userId: string, message: string) {
    // Send notification
    return { sent: true };
  },
  
  async markAsRead(notificationId: string) {
    // Mark as read
    return { success: true };
  },
  
  async getUnread(userId: string) {
    // Get unread count
    return { count: 5 };
  }
};
How it’s registered:
  • Three functions: notifications_send, notifications_mark_as_read, notifications_get_unread
  • Source: src/ai/functions/notifications.ts#notifications.methodName

Function registration and loading

NAVAI automatically discovers and loads functions from your configured directories.

Frontend function loading

Functions are loaded using module loaders (typically from Vite or webpack):
// Load all function modules from src/ai/functions
const functionModules = import.meta.glob('./ai/functions/**/*.ts');

const voiceAgent = useWebVoiceAgent({
  navigate,
  moduleLoaders: functionModules,
  defaultRoutes,
  // ...
});
The runtime loads and processes each module:
// From packages/voice-frontend/src/functions.ts:280-322
export async function loadNavaiFunctions(
  functionModuleLoaders: NavaiFunctionModuleLoaders
): Promise<NavaiFunctionsRegistry> {
  const byName = new Map<string, NavaiFunctionDefinition>();
  const ordered: NavaiFunctionDefinition[] = [];
  const warnings: string[] = [];
  const usedNames = new Set<string>();

  const entries = Object.entries(functionModuleLoaders)
    .filter(([path]) => !path.endsWith(".d.ts"))
    .sort(([a], [b]) => a.localeCompare(b));

  for (const [path, load] of entries) {
    try {
      const imported = (await load()) as NavaiLoadedModule;
      const exportEntries = Object.entries(imported);

      if (exportEntries.length === 0) {
        warnings.push(`[navai] Ignored ${path}: module has no exports.`);
        continue;
      }

      const defsBeforeModule = ordered.length;
      for (const [exportName, value] of exportEntries) {
        const { defs, warnings: exportWarnings } = collectFromExportValue(path, exportName, value, usedNames);
        warnings.push(...exportWarnings);

        for (const definition of defs) {
          byName.set(definition.name, definition);
          ordered.push(definition);
        }
      }

      if (ordered.length === defsBeforeModule) {
        warnings.push(`[navai] Ignored ${path}: module has no callable exports.`);
      }
    } catch (error) {
      warnings.push(`[navai] Failed to load ${path}: ${toErrorMessage(error)}`);
    }
  }

  return { byName, ordered, warnings };
}

Backend function loading

The backend automatically scans configured directories:
// From packages/voice-backend/src/runtime.ts:35-74
export async function resolveNavaiBackendRuntimeConfig(
  options: ResolveNavaiBackendRuntimeConfigOptions = {}
): Promise<ResolveNavaiBackendRuntimeConfigResult> {
  const warnings: string[] = [];
  const env = options.env ?? process.env;
  const baseDir = options.baseDir ?? process.cwd();
  const defaultFunctionsFolder = options.defaultFunctionsFolder ?? DEFAULT_FUNCTIONS_FOLDER;
  const functionsFolders =
    readOptional(options.functionsFolders) ?? readFirstOptionalEnv(env, FUNCTIONS_ENV_KEYS) ?? defaultFunctionsFolder;
  const includeExtensions = options.includeExtensions ?? DEFAULT_EXTENSIONS;
  const exclude = options.exclude ?? DEFAULT_EXCLUDES;

  const indexedModules = await scanModules(baseDir, includeExtensions, exclude);
  const configuredTokens = functionsFolders
    .split(",")
    .map((value) => value.trim())
    .filter(Boolean);

  const tokens = configuredTokens.length > 0 ? configuredTokens : [defaultFunctionsFolder];
  const matchers = tokens.map((token) => createPathMatcher(token));

  let matched = indexedModules.filter((entry) => matchers.some((matcher) => matcher(entry.normalizedPath)));

  if (matched.length === 0 && configuredTokens.length > 0) {
    warnings.push(
      `[navai] NAVAI_FUNCTIONS_FOLDERS did not match any module: "${functionsFolders}". Falling back to "${defaultFunctionsFolder}".`
    );
    const fallbackMatcher = createPathMatcher(defaultFunctionsFolder);
    matched = indexedModules.filter((entry) => fallbackMatcher(entry.normalizedPath));
  }

  const functionModuleLoaders: NavaiFunctionModuleLoaders = Object.fromEntries(
    matched.map((entry) => [
      entry.rawPath,
      () => import(pathToFileURL(entry.absPath).href)
    ])
  );

  return { functionModuleLoaders, warnings };
}
The backend automatically scans src/ai/functions-modules by default. Configure NAVAI_FUNCTIONS_FOLDERS to scan different directories.

Function calling flow

When the AI agent needs to execute a function, NAVAI follows this flow:

Frontend function execution

// From packages/voice-frontend/src/agent.ts:108-124
const executeAppFunction = async (requestedName: string, payload: Record<string, unknown> | null | undefined) => {
  const requested = requestedName.trim().toLowerCase();
  const frontendDefinition = functionsRegistry.byName.get(requested);

  if (frontendDefinition) {
    try {
      const result = await frontendDefinition.run(payload ?? {}, options);
      return { ok: true, function_name: frontendDefinition.name, source: frontendDefinition.source, result };
    } catch (error) {
      return {
        ok: false,
        function_name: frontendDefinition.name,
        error: "Function execution failed.",
        details: toErrorMessage(error)
      };
    }
  }

  // If not found in frontend, check backend...
};

Backend function execution

If not found in frontend, the call is forwarded to the backend:
// From packages/voice-frontend/src/agent.ts:126-163
const backendDefinition = backendFunctionsByName.get(requested);
if (!backendDefinition) {
  return {
    ok: false,
    error: "Unknown or disallowed function.",
    available_functions: availableFunctionNames
  };
}

if (!options.executeBackendFunction) {
  return {
    ok: false,
    function_name: backendDefinition.name,
    error: "Backend function execution is not configured."
  };
}

try {
  const result = await options.executeBackendFunction({
    functionName: backendDefinition.name,
    payload: payload ?? null
  });

  return {
    ok: true,
    function_name: backendDefinition.name,
    source: backendDefinition.source ?? "backend",
    result
  };
} catch (error) {
  return {
    ok: false,
    function_name: backendDefinition.name,
    error: "Function execution failed.",
    details: toErrorMessage(error)
  };
}

Backend HTTP execution

The backend client makes an HTTP POST request:
// From packages/voice-frontend/src/backend.ts:150-180
const executeFunction: ExecuteNavaiBackendFunction = async (input: ExecuteNavaiBackendFunctionInput) => {
  const response = await fetchImpl(functionsExecuteUrl, {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({
      function_name: input.functionName,
      payload: input.payload
    })
  });

  if (!response.ok) {
    throw new Error(await readTextSafe(response));
  }

  const payload = await readJsonSafe(response);
  if (!isRecord(payload)) {
    throw new Error("Invalid backend function response.");
  }

  if (payload.ok !== true) {
    const details =
      typeof payload.details === "string"
        ? payload.details
        : typeof payload.error === "string"
          ? payload.error
          : "Backend function failed.";
    throw new Error(details);
  }

  return payload.result;
};
The backend server handles the request:
// From packages/voice-backend/src/index.ts:298-330
app.post(functionsExecutePath, async (req: Request, res: Response, next: NextFunction) => {
  try {
    const runtime = await getRuntime();
    const input = req.body as { function_name?: unknown; payload?: unknown } | undefined;
    const functionName = typeof input?.function_name === "string" ? input.function_name.trim().toLowerCase() : "";

    if (!functionName) {
      res.status(400).json({ error: "function_name is required." });
      return;
    }

    const definition = runtime.registry.byName.get(functionName);
    if (!definition) {
      res.status(404).json({
        error: "Unknown or disallowed function.",
        available_functions: runtime.registry.ordered.map((item) => item.name)
      });
      return;
    }

    const payload = isObjectRecord(input?.payload) ? input.payload : {};
    const result = await definition.run(payload, { req });

    res.json({
      ok: true,
      function_name: definition.name,
      source: definition.source,
      result
    });
  } catch (error) {
    next(error);
  }
});

Parameter handling

NAVAI provides flexible parameter passing for different function patterns:

Arguments array

Pass positional arguments using payload.args:
export function greet(name: string, greeting: string = "Hello") {
  return `${greeting}, ${name}!`;
}
AI calls with:
{
  "function_name": "greet",
  "payload": {
    "args": ["Alice", "Good morning"]
  }
}

Object payload

Pass a single object with named properties:
export function sendEmail(payload: { to: string; subject: string; body: string }) {
  const { to, subject, body } = payload;
  // Send email...
  return { sent: true };
}
AI calls with:
{
  "function_name": "send_email",
  "payload": {
    "to": "[email protected]",
    "subject": "Hello",
    "body": "Message content"
  }
}

Argument building logic

NAVAI automatically adapts the payload to your function signature:
// From packages/voice-frontend/src/functions.ts:66-81
function buildInvocationArgs(payload: NavaiFunctionPayload, context: NavaiFunctionContext, targetArity: number): unknown[] {
  const directArgs = readArray(payload.args ?? payload.arguments);
  const args = directArgs.length > 0 ? [...directArgs] : [];

  if (args.length === 0 && "value" in payload) {
    args.push(payload.value);
  } else if (args.length === 0 && Object.keys(payload).length > 0) {
    args.push(payload);
  }

  if (targetArity > args.length) {
    args.push(context);
  }

  return args;
}
If your function expects more parameters than provided in the payload, NAVAI automatically appends the context object as the last argument.

Direct function tools

For functions with valid tool names, NAVAI creates direct tool aliases:
// From packages/voice-frontend/src/agent.ts:200-215
const directFunctionTools = directFunctionToolNames.map((functionName) =>
  tool({
    name: functionName,
    description: `Direct alias for execute_app_function("${functionName}").`,
    parameters: z.object({
      payload: z
        .record(z.string(), z.unknown())
        .nullable()
        .optional()
        .describe(
          "Payload object. Optional. Use payload.args as array for function args, payload.constructorArgs for class constructors, payload.methodArgs for class methods."
        )
    }),
    execute: async ({ payload }) => await executeAppFunction(functionName, payload ?? null)
  })
);
This allows the AI to call functions directly:
// Instead of: execute_app_function("send_message", {...})
// AI can call: send_message({...})

Reserved tool names

Some names can’t be used as direct tools:
// From packages/voice-frontend/src/agent.ts:40-41
const RESERVED_TOOL_NAMES = new Set(["navigate_to", "execute_app_function"]);
const TOOL_NAME_REGEXP = /^[a-zA-Z0-9_-]{1,64}$/;
Functions with reserved or invalid names are still accessible via execute_app_function.

Best practices

Function names should clearly indicate what they do:✅ Good: sendMessage, getUserOrders, updateProfile
❌ Bad: doThing, handler, func1
Each function should do one thing well:
// ✅ Good: Separate focused functions
export function getUser(userId: string) { /* ... */ }
export function updateUser(userId: string, data: any) { /* ... */ }
export function deleteUser(userId: string) { /* ... */ }

// ❌ Bad: One function doing everything
export function manageUser(action: string, userId: string, data?: any) { /* ... */ }
Return helpful errors that the AI can communicate to the user:
export async function cancelOrder(orderId: string) {
  const order = await getOrder(orderId);
  
  if (!order) {
    throw new Error(`Order ${orderId} not found`);
  }
  
  if (order.status === 'shipped') {
    throw new Error('Cannot cancel order that has already shipped');
  }
  
  // Cancel order...
}
Never expose sensitive operations to the frontend:
// ✅ Backend function (secure)
export async function processPayment(orderId: string) {
  const apiKey = process.env.STRIPE_SECRET_KEY;
  // Process payment securely
}

// ❌ Frontend function (insecure)
export async function processPayment(orderId: string, apiKey: string) {
  // DON'T expose API keys to the frontend!
}
Return objects with clear structure that the AI can parse:
// ✅ Good: Structured response
return {
  success: true,
  message: "Order placed successfully",
  orderId: "ORD-12345",
  total: 49.99
};

// ❌ Bad: String-only response
return "Order ORD-12345 placed for $49.99";

Testing functions

Test your functions independently before integrating with voice:
import { loadNavaiFunctions } from '@navai/voice-frontend';
import { describe, it, expect } from 'vitest';

describe('NAVAI Functions', () => {
  it('should load all functions', async () => {
    const moduleLoaders = import.meta.glob('./ai/functions/**/*.ts');
    const registry = await loadNavaiFunctions(moduleLoaders);
    
    expect(registry.ordered.length).toBeGreaterThan(0);
    expect(registry.warnings).toHaveLength(0);
  });
  
  it('should execute sendMessage function', async () => {
    const registry = await loadNavaiFunctions(moduleLoaders);
    const sendMessage = registry.byName.get('send_message');
    
    expect(sendMessage).toBeDefined();
    
    const result = await sendMessage!.run(
      { args: ['user123', 'Hello!'] },
      { navigate: vi.fn() }
    );
    
    expect(result).toMatchObject({
      success: true,
      message: expect.any(String)
    });
  });
});

Next steps

Frontend setup

Set up function execution in your React app

Backend setup

Configure backend function execution

Frontend functions

Detailed function configuration options

API reference

Browse function loading API

Build docs developers (and LLMs) love