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.
User requests action
The user says something like “send a message to John” or “get my latest orders”.
AI agent calls tool
The AI calls execute_app_function (or a direct function tool) with the function name and parameters.
NAVAI resolves function
NAVAI checks if the function exists in the frontend registry. If not, it checks backend functions.
Function executes
The function runs with the provided payload and context, performing the requested action.
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 ;
};
Unique identifier for the function. Used by the AI agent to call it. Automatically normalized to lowercase with underscores.
Human-readable description of what the function does. Helps the AI understand when to call it.
File path or identifier indicating where this function comes from. Used for debugging and logging.
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:
Frontend functions
Backend functions
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 actionsExecute on the server with access to:
Database queries : Direct access to your database
External APIs : Call third-party services securely
File system : Read/write files on the server
Sensitive operations : Process payments, send emails, access secrets
Context type: // From packages/voice-backend/src/functions.ts:3
export type NavaiFunctionContext = Record < string , unknown >;
Backend context includes the Express request object: context . req // Express Request object
Best for : Data persistence, sensitive operations, external service integration
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.
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({...})
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
Write descriptive function names
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 ) { /* ... */ }
Provide clear error messages
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...
}
Use backend functions for sensitive operations
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