Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 32 additions & 0 deletions examples/nextjs/pages/simple.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ import {
useTracks,
SessionEvent,
useEvents,
useRpc,
rpc,
} from '@livekit/components-react';
import { Track, TokenSource, MediaDeviceFailure } from 'livekit-client';
import type { NextPage } from 'next';
Expand Down Expand Up @@ -71,6 +73,23 @@ const SimpleExample: NextPage = () => {
);
}, []);

const { performRpc } = useRpc(session, {
getUserLocation: rpc.json(async (payload: { highAccuracy: boolean }, data) => {
const position = await new Promise<GeolocationPosition>((resolve, reject) => {
navigator.geolocation.getCurrentPosition(resolve, reject, {
enableHighAccuracy: payload.highAccuracy,
timeout: data.responseTimeout * 1000,
});
});
return {
latitude: position.coords.latitude,
longitude: position.coords.longitude,
};
}),
});

const [participantIdentity, setParticipantIdentity] = useState('');

return (
<div className={styles.container} data-lk-theme="default">
<main className={styles.main}>
Expand All @@ -82,6 +101,19 @@ const SimpleExample: NextPage = () => {
{connect ? 'Disconnect' : 'Connect'}
</button>
)}

<input type="text" placeholder="participant id" value={participantIdentity} onChange={e => setParticipantIdentity(e.target.value)} />
<button onClick={async () => {
const result = await performRpc(rpc.json<{ highAccuracy: boolean }, { latitude: number; longitude: number }>({
destinationIdentity: participantIdentity,
method: 'getUserLocation',
payload: { highAccuracy: true },
}));
console.log('Result:', result);
}}>
Call getUserLocation
</button>

<SessionProvider session={session}>
<RoomName />
<ConnectionState />
Expand Down
17 changes: 17 additions & 0 deletions packages/react/src/hooks/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,3 +70,20 @@ export {
} from './useAgent';
export * from './useEvents';
export * from './useSessionMessages';
export {
type RpcRawHandler,
type RpcMethodDescriptor,
type RpcMethod,
type PerformRpcDescriptor,
type RpcJsonParams,
type UseRpcOptions,
type PerformRpcFn,
type UseRpcReturn,
rpc,
useRpc,
} from './useRpc';
export {
type SchemaLike,
type ClientToolDefinition,
useClientTools,
} from './useClientTools';
4 changes: 4 additions & 0 deletions packages/react/src/hooks/useAgent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,8 @@ type AgentStateCommon = {

agentParticipant: RemoteParticipant | null;
workerParticipant: RemoteParticipant | null;

session: UseSessionReturn;
};
};

Expand Down Expand Up @@ -741,6 +743,7 @@ export function useAgent(session?: SessionStub): UseAgentReturn {
agentParticipant,
workerParticipant,
emitter,
session: session! as UseSessionReturn,
},
};

Expand Down Expand Up @@ -848,6 +851,7 @@ export function useAgent(session?: SessionStub): UseAgentReturn {
agentParticipantAttributes,
emitter,
agentParticipant,
session,
state,
videoTrack,
audioTrack,
Expand Down
156 changes: 156 additions & 0 deletions packages/react/src/hooks/useClientTools.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
import * as React from 'react';
import { type RpcInvocationData } from 'livekit-client';

import type { UseAgentReturn } from './useAgent';
import { useRpc, type RpcMethod } from './useRpc';

// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------

/**
* Any object with a `parse` method that validates and returns typed data, and a `toJSONSchema`
* method that returns a JSON Schema representation.
*
* This aligns with zod's interface without depending on it. Zod provides `.parse()` natively
* and `.toJSONSchema()` via `z.toJSONSchema(schema)`. Other schema libraries can also satisfy
* this interface with minimal wrapping.
*
* @beta
*/
export type SchemaLike<T> = {
/** Validate and parse the input. Should throw if validation fails. */
parse: (input: unknown) => T;
/**
* Return a JSON Schema representation of the parameters.
* Must be `{ type: "object", properties: { ... } }` at the top level, since the agent
* framework maps parameters to Python kwargs / JS named arguments.
*/
toJSONSchema: () => Record<string, unknown>;
};

/**
* Definition for a single client tool.
* @beta
*/
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export type ClientToolDefinition<TParams = any> = {
/** Description of the tool, presented to the LLM by the agent framework. */
description: string;

/**
* Schema for validating/parsing incoming payloads AND generating JSON Schema for the manifest.
*
* Must satisfy the {@link SchemaLike} interface. Compatible with zod schemas and any other
* library with conforming `parse` and `toJSONSchema` methods.
*/
parameters: SchemaLike<TParams>;

/** The function called when the agent invokes this tool. */
execute: (params: TParams, context: RpcInvocationData) => Promise<unknown>;
};

/** Version of the client tool manifest attribute format. */
const CLIENT_TOOL_MANIFEST_VERSION = 1;

/** Prefix for participant attributes that advertise client tools. */
const CLIENT_TOOL_ATTRIBUTE_PREFIX = 'lk.client_tools.';

// ---------------------------------------------------------------------------
// useClientTools hook
// ---------------------------------------------------------------------------

/**
* Declares tools that an AI agent can call on the frontend.
*
* Each tool's description, parameter schema, and implementation are defined here on the client.
* The hook publishes the tool manifest as participant attributes so the agent framework can
* discover them, and registers RPC handlers so the agent can invoke them.
*
* On the agent side, each tool is referenced by name via `client_tool(name="toolName")`.
*
* @example
* ```tsx
* useClientTools(agent, {
* getUserLocation: {
* description: "Get the user's browser geolocation",
* parameters: z.object({ highAccuracy: z.boolean() }),
* execute: async ({ highAccuracy }) => {
* const pos = await getPosition(highAccuracy);
* return { lat: pos.coords.latitude, lng: pos.coords.longitude };
* },
* },
* });
* ```
*
* @beta
*/
export function useClientTools(
agent: UseAgentReturn,
tools: Record<string, ClientToolDefinition>,
): void {
const session = agent.internal.session;
const { room } = session;

// --- Ref for latest tool definitions (same pattern as useRpc) ---
const toolsRef = React.useRef(tools);
toolsRef.current = tools;

// --- Convert tool definitions into RpcMethods for useRpc ---
const rpcMethods = React.useMemo(() => {
const methods: Record<string, RpcMethod> = {};
for (const [name, tool] of Object.entries(tools)) {
methods[name] = {
parse: (raw: string) => {
const parsed = JSON.parse(raw);
return tool.parameters.parse(parsed);
},
serialize: (val: unknown) => JSON.stringify(val),
handler: async (payload: unknown, data: RpcInvocationData) => {
return toolsRef.current[name]!.execute(payload, data);
},
};
}
return methods;
}, [tools]);

// --- Register RPC handlers via useRpc, scoped to agent participant ---
useRpc(session, rpcMethods, { from: agent.identity });

// --- Publish tool manifest as participant attributes ---
const toolNamesKey = React.useMemo(() => Object.keys(tools).sort().join('\0'), [tools]);

React.useEffect(() => {
const attributes: Record<string, string> = {};
for (const [name, tool] of Object.entries(toolsRef.current)) {
let jsonSchema: Record<string, unknown>;
try {
jsonSchema = tool.parameters.toJSONSchema();
} catch (e) {
throw new Error(
`useClientTools: Failed to generate JSON Schema for tool "${name}". ` +
`Ensure your parameters schema implements toJSONSchema() correctly: ${e}`,
);
}

const manifest: Record<string, unknown> = {
version: CLIENT_TOOL_MANIFEST_VERSION,
description: tool.description,
parameters: jsonSchema,
};

attributes[`${CLIENT_TOOL_ATTRIBUTE_PREFIX}${name}`] = JSON.stringify(manifest);
}

room.localParticipant.setAttributes(attributes);

return () => {
// Clear attributes on unmount
const cleared: Record<string, string> = {};
for (const name of Object.keys(toolsRef.current)) {
cleared[`${CLIENT_TOOL_ATTRIBUTE_PREFIX}${name}`] = '';
}
room.localParticipant.setAttributes(cleared);
};
}, [room, toolNamesKey]);
}
Loading
Loading