Skip to content
Open
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
6 changes: 6 additions & 0 deletions apps/example-apple/app.json
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,12 @@
"calendarPermission": "The app needs to access your calendar."
}
],
[
"expo-contacts",
{
"contactsPermission": "Allow $(PRODUCT_NAME) to access your contacts."
}
],
"react-native-bottom-tabs",
[
"expo-build-properties",
Expand Down
1 change: 1 addition & 0 deletions apps/example-apple/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@
"expo-build-properties": "~0.14.8",
"expo-calendar": "~14.1.4",
"expo-clipboard": "~7.1.5",
"expo-contacts": "~14.2.5",
"expo-document-picker": "~13.1.6",
"expo-status-bar": "2.2.3",
"nativewind": "^4.1.23",
Expand Down
9 changes: 9 additions & 0 deletions apps/example-apple/src/screens/LLMScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,14 +16,20 @@ import Animated, { useDerivedValue } from 'react-native-reanimated'
import {
checkCalendarEvents,
createCalendarEvent,
getBatteryLevel,
getCurrentTime,
listContacts,
openEmail,
} from '../tools'

const apple = createAppleProvider({
availableTools: {
getCurrentTime,
createCalendarEvent,
checkCalendarEvents,
getBatteryLevel,
listContacts,
openEmail,
},
})

Expand Down Expand Up @@ -78,6 +84,9 @@ export default function LLMScreen() {
getCurrentTime,
createCalendarEvent,
checkCalendarEvents,
getBatteryLevel,
listContacts,
openEmail,
},
})

Expand Down
132 changes: 131 additions & 1 deletion apps/example-apple/src/tools.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
import { tool } from 'ai'
import * as Battery from 'expo-battery'
import * as Calendar from 'expo-calendar'
import * as Contacts from 'expo-contacts'
import { Alert, Linking } from 'react-native'
import { z } from 'zod'

/**
Expand Down Expand Up @@ -74,6 +77,133 @@ export const getCurrentTime = tool({
description: 'Get current time and date',
inputSchema: z.object({}),
execute: async () => {
return `Current time is: ${new Date().toUTCString()}`
const now = new Date()
const currentDay = now.toDateString()
const currentTime = now.toTimeString()

return `Current day: ${currentDay}\nCurrent time: ${currentTime}`
},
})

/**
* Get device battery level
*/
export const getBatteryLevel = tool({
description: 'Get current battery level of the device',
inputSchema: z.object({}),
execute: async () => {
const batteryLevel = await Battery.getBatteryLevelAsync()
const batteryState = await Battery.getBatteryStateAsync()

const percentage = Math.round(batteryLevel * 100)
const state = Battery.BatteryState[batteryState]

return {
level: percentage,
state,
message: `Battery level: ${percentage}% (${state})`,
}
},
})

/**
* List all contacts from the device
*/
export const listContacts = tool({
description: 'List all contacts from the device with their basic information',
inputSchema: z.object({
limit: z
.number()
.optional()
.describe('Maximum number of contacts to return'),
fields: z
.array(z.string())
.optional()
.describe(
'Specific fields to retrieve (firstName, lastName, emails, phoneNumbers, etc.)'
),
}),
execute: async ({ limit, fields }) => {
// Request permissions first
const { status } = await Contacts.requestPermissionsAsync()

if (status !== 'granted') {
return {
error: 'Contacts permission not granted',
contacts: [],
message: 'Permission to access contacts was denied',
}
}

// Define which fields to retrieve
const contactFields: Contacts.FieldType[] = fields
? fields.map((field) => {
// Map common field names to Contacts.Fields constants
const fieldMap: Record<string, Contacts.FieldType> = {
firstName: Contacts.Fields.FirstName,
lastName: Contacts.Fields.LastName,
emails: Contacts.Fields.Emails,
phoneNumbers: Contacts.Fields.PhoneNumbers,
company: Contacts.Fields.Company,
jobTitle: Contacts.Fields.JobTitle,
name: Contacts.Fields.Name,
}
return fieldMap[field] || (field as Contacts.FieldType)
})
: [
Contacts.Fields.FirstName,
Contacts.Fields.LastName,
Contacts.Fields.Emails,
Contacts.Fields.PhoneNumbers,
Contacts.Fields.Company,
]

try {
const { data } = await Contacts.getContactsAsync({
fields: contactFields,
pageSize: limit || 100,
pageOffset: 0,
})

const processedContacts = data.map((contact) => ({
id: contact.id,
firstName: contact.firstName || '',
lastName: contact.lastName || '',
name:
contact.name ||
`${contact.firstName || ''} ${contact.lastName || ''}`.trim(),
emails: contact.emails?.map((email) => email.email) || [],
phoneNumbers: contact.phoneNumbers?.map((phone) => phone.number) || [],
company: contact.company || '',
jobTitle: contact.jobTitle || '',
}))

return {
contacts: processedContacts,
count: processedContacts.length,
message: `Found ${processedContacts.length} contacts`,
}
} catch (error) {
return {
error: 'Failed to retrieve contacts',
contacts: [],
message: `Error: ${error instanceof Error ? error.message : 'Unknown error'}`,
}
}
},
})

/**
* Open email app with pre-filled recipient
*/
export const openEmail = tool({
description: 'Open email app',
inputSchema: z.object({
email: z.string().describe('Email address to send to'),
subject: z.string().optional().describe('Email subject line'),
}),
execute: async ({ email, subject }) => {
Linking.openURL(`mailto:${email}?subject=${subject}`)
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bug: Email Tool Malforms URLs with Undefined Parameters

The openEmail tool unconditionally includes the optional subject parameter in the mailto URL. When subject is undefined, it's stringified as "undefined", creating a malformed URL instead of omitting the parameter.

Fix in Cursor Fix in Web

return { message: 'Email app opened' }
},
})
3 changes: 3 additions & 0 deletions bun.lock
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@
"expo-build-properties": "~0.14.8",
"expo-calendar": "~14.1.4",
"expo-clipboard": "~7.1.5",
"expo-contacts": "~14.2.5",
"expo-document-picker": "~13.1.6",
"expo-status-bar": "2.2.3",
"nativewind": "^4.1.23",
Expand Down Expand Up @@ -1268,6 +1269,8 @@

"expo-constants": ["[email protected]", "", { "dependencies": { "@expo/config": "~11.0.12", "@expo/env": "~1.0.7" }, "peerDependencies": { "expo": "*", "react-native": "*" } }, "sha512-byBjGsJ6T6FrLlhOBxw4EaiMXrZEn/MlUYIj/JAd+FS7ll5X/S4qVRbIimSJtdW47hXMq0zxPfJX6njtA56hHA=="],

"expo-contacts": ["[email protected]", "", { "peerDependencies": { "expo": "*", "react": "*", "react-native": "*" } }, "sha512-yiVmXrLKBCcBkwsaHFlbs0f7UwE2t7Aa1NBOK4Y06ya0Y5WyE6I/P5ZAtWNjXnKmbV7iNKAiPUzqVaNazhCtWA=="],

"expo-document-picker": ["[email protected]", "", { "peerDependencies": { "expo": "*" } }, "sha512-8FTQPDOkyCvFN/i4xyqzH7ELW4AsB6B3XBZQjn1FEdqpozo6rpNJRr7sWFU/93WrLgA9FJEKpKbyr6XxczK6BA=="],

"expo-file-system": ["[email protected]", "", { "peerDependencies": { "expo": "*", "react-native": "*" } }, "sha512-HJw/m0nVOKeqeRjPjGdvm+zBi5/NxcdPf8M8P3G2JFvH5Z8vBWqVDic2O58jnT1OFEy0XXzoH9UqFu7cHg9DTQ=="],
Expand Down