Adding AI to Todoist with Google Apps Script and OpenAI
This simple script adds useful AI to Todoist and runs in the cloud on Google Apps Script.
I use Todoist to run my life, and my dream is for it to be able to complete certain types of tasks for me. With OpenAI Operator and Anthropic Computer Use this is getting closer and closer to reality. Yes, there is a risk that Todoist spends all of my money on paper clips. But there is also the upside that it will eventually do my weekly shopping, pay my bills and call back the dentist's office. Google's new Ask for Me is promising too, even if right now it's just going to bother nail salons out of existence.
I already put together an Alexa replacement using a Raspberry Pi and the OpenAI realtime API. It switches lights on and off, adds things to my to do list, figures out when the next L is coming and more (I'll blog more about this soon). One thing I learned is that this kind of thing can get pretty expensive. I can see why Amazon is procrastinating on an LLM Alexa. But costs keep going down, and the future will get more evenly distributed over time.
The first version of this script has two objectives. Respond to tasks, and create calendar events. Here's the script:
// config - ok to leave PERPLEXITY_API_TOKEN blank | |
const OPENAI_API_TOKEN = ''; | |
const PERPLEXITY_API_TOKEN = '' | |
const TODOIST_API_TOKEN = ''; | |
const AI_TASK_LABEL = 'ai'; | |
const AI_MESSAGE_PREFIX = 'AI:'; | |
const MAX_GENERATION_ATTEMPTS = 10; | |
const OPENAI_MODEL = 'gpt-4o' | |
const PERPLEXITY_MODEL = 'sonar-pro' | |
const IMAGE_MIME_TYPES = [ | |
'image/jpeg', | |
'image/jpg', | |
'image/png', | |
'image/webp', | |
'image/gif' | |
]; | |
function trigger() { | |
runAssistant(); | |
} | |
function runAssistant() { | |
// process all open tasks with the AI_TASK_LABEL label | |
tasks = getAiTasks(); | |
tasks.forEach(task => { | |
checkTask(task); | |
}); | |
} | |
function checkTask(task) { | |
// if the user was the last message then we generate a response | |
var responseNeeded = true; | |
const comments = getComments(task.id); | |
comments.forEach(comment => { | |
if (comment?.content) { | |
responseNeeded = !comment.content.startsWith(AI_MESSAGE_PREFIX); | |
} | |
}); | |
if(responseNeeded){ | |
processTask(task, comments); | |
} | |
} | |
function processTask(task, comments) { | |
// build the conversation from the task and comments... | |
// the message array is in openai chat completion format | |
const messages = []; | |
const now = new Date(); | |
const timeZone = Session.getScriptTimeZone(); | |
const timeZoneName = Utilities.formatDate(now, timeZone, "z"); | |
// Format the date. This pattern outputs: "August 10, 2025 20:00:00 PST" | |
const formattedDate = Utilities.formatDate(now, timeZone, "MMMM dd, yyyy HH:mm:ss z"); | |
messages.push({ | |
role: 'developer', | |
content: `You are a helpful assistant that works with Todoist tasks. You are given the current task and any comments and try to help as best as you can. If the user is researching you respond with the information they're looking for. If you have a tool that can help with the task you call it. If you believe that you have fully completed the task then you call the complete_task function to close it. The current task ID is ${task.id}. The current date and time is ${formattedDate}.` | |
}); | |
if (task?.content) { | |
messages.push({ | |
role: 'user', | |
content: task.content | |
}); | |
} | |
if (task?.description) { | |
messages.push({ | |
role: 'user', | |
content: task.description | |
}); | |
} | |
comments.forEach(comment => { | |
if (comment?.content) { | |
if (comment.content.startsWith(AI_MESSAGE_PREFIX)) { | |
// AI message | |
messages.push({ | |
role: 'assistant', | |
content: comment.content.substring(AI_MESSAGE_PREFIX.length).trim() | |
}); | |
} else { | |
// User message - might include an image | |
content = [] | |
// the text part of the comment | |
content.push({type: 'text', text: comment.content}); | |
// if there is an immage attachment add it | |
if (comment.attachment) { | |
if (IMAGE_MIME_TYPES.includes(comment.attachment.file_type)) { | |
var url = ''; | |
if (comment.attachment.tn_l) { | |
// use large thumbnail if it exists | |
url = comment.attachment.tn_l[0]; | |
} else { | |
// full attachment - may fail if too large | |
url = comment.attachment.file_url; | |
} | |
// download to base 64 - openai can't access Todoist attachments from the URL | |
// see https://platform.openai.com/docs/guides/vision#uploading-base64-encoded-images | |
base64 = getAttachment(url); | |
content.push({ | |
type: 'image_url', | |
image_url: { | |
url: `data:${comment.attachment.file_type};base64,${base64}` | |
} | |
}); | |
} | |
} | |
messages.push({ | |
role: 'user', | |
content: content | |
}); | |
} | |
} | |
}); | |
if (messages[messages.length - 1].role = 'user') { | |
// if the user is the last in the thread generate an AI response | |
generateAiResponse(messages, task.id, timeZoneName); | |
} | |
} | |
function generateAiResponse(messages, taskId, timeZoneName) { | |
const payload = { | |
model: OPENAI_MODEL, | |
messages: messages, | |
tools: getTools(timeZoneName) | |
}; | |
// loop to allow for tool use | |
for(var retry = 0; retry < MAX_GENERATION_ATTEMPTS; retry++) { | |
const options = { | |
method: 'post', | |
contentType: 'application/json', | |
headers: { | |
'Authorization': 'Bearer ' + OPENAI_API_TOKEN | |
}, | |
payload: JSON.stringify(payload) | |
}; | |
const response = UrlFetchApp.fetch('https://api.openai.com/v1/chat/completions', options); | |
const result = JSON.parse(response.getContentText()); | |
if (result.choices && result.choices.length > 0) { | |
if (result.choices[0].message.tool_calls && result.choices[0].message.tool_calls.length > 0) { | |
// we have at least one tool request, add the requst message to the conversation | |
payload.messages.push(result.choices[0].message); | |
// run all the tools... | |
result.choices[0].message.tool_calls.forEach(tool_call => { | |
const args = JSON.parse(tool_call['function'].arguments); | |
var result = ''; | |
Logger.log(`Calling ${tool_call['function'].name} (call ID ${tool_call.id})`) | |
switch(tool_call['function'].name) { | |
case 'create_event': | |
try { | |
result = createCalendarAppointment(args.title, args.start, args.end, args?.description, args?.location, args?.guests); | |
} catch (error) { | |
result = error.message; | |
} | |
break; | |
case 'complete_task': | |
try { | |
result = closeTask(args.task_id); | |
} catch (error) { | |
result = error.message; | |
} | |
break; | |
case 'answer_question': | |
try { | |
result = generatePerplexityResponse(args.question); | |
} catch (error) { | |
result = error.message; | |
} | |
break; | |
default: | |
result = 'Unknown function?!'; | |
break; | |
} | |
Logger.log(`Tool response: ${result.substring(0, 50)}...`) | |
payload.messages.push({ | |
"role": "tool", | |
"tool_call_id": tool_call.id, | |
"content": result | |
}); | |
}) | |
continue; | |
} else { | |
// message back from AI, post it as a comment to the task | |
const aiMessage = result.choices[0].message.content; | |
Logger.log(`AI Response: ${aiMessage.substring(0, 50)}...`) | |
addComment(taskId, AI_MESSAGE_PREFIX + ' ' + aiMessage) | |
break; | |
} | |
} | |
} | |
} | |
function generatePerplexityResponse(question) { | |
const messages = []; | |
messages.push({ | |
role: 'system', | |
content: 'You are an artificial intelligence assistant and you answer questions for your user. Your answer will be interpreted by another AI so do not inclue any formating or special text in your answer. Be brief and answer the question in a single concise paragraph. You never ask any clarifying questions or suggest any follow up, just respond as best as you can.' | |
}); | |
messages.push({ | |
role: 'user', | |
content: question | |
}); | |
const payload = { | |
model: PERPLEXITY_MODEL, | |
messages: messages | |
}; | |
const options = { | |
method: 'post', | |
contentType: 'application/json', | |
headers: { | |
'Authorization': 'Bearer ' + PERPLEXITY_API_TOKEN | |
}, | |
payload: JSON.stringify(payload) | |
}; | |
const response = UrlFetchApp.fetch('https://api.perplexity.ai/chat/completions', options); | |
const result = JSON.parse(response.getContentText()); | |
return result.choices[0].message.content; | |
} | |
function getTools(timeZoneName) { | |
tools = []; | |
// complete a todoist task by ID, call closeTask() | |
tools.push({ | |
"type": "function", | |
"function": { | |
"name": "complete_task", | |
"description": "Closes or completes a task", | |
"parameters": { | |
"type": "object", | |
"properties": { | |
"task_id": { | |
"type": "string", | |
"description": "The ID of the task to complete" | |
} | |
}, | |
"required": ["task_id"] | |
} | |
} | |
}); | |
// create a meeting on the user's calendar, call createCalendarAppointment() | |
tools.push({ | |
"type": "function", | |
"function": { | |
"name": "create_event", | |
"description": "Adds an event to the user's calendar. The start and end timestamps must be in 'MMMM D, YYYY HH:mm:ss z' javascript format", | |
"parameters": { | |
"type": "object", | |
"properties": { | |
"title": { | |
"type": "string", | |
"description": "Name of the calendar event, i.e. 'Lunch with Bob'." | |
}, | |
"start": { | |
"type": "string", | |
"description": `Start time for the event, i.e. 'August 10, 2025 20:00:00 ${timeZoneName}', assume ${timeZoneName} if the user does not specify` | |
}, | |
"end": { | |
"type": "string", | |
"description": `End time for the event, i.e. 'August 10, 2025 21:00:00 ${timeZoneName}', assume ${timeZoneName} if the user does not specify, assume 1 hour duration if no end time or length is given` | |
}, | |
"description": { | |
"type": "string", | |
"description": "Optional description for the event" | |
}, | |
"location": { | |
"type": "string", | |
"description": "Optional location for the event" | |
}, | |
"guests": { | |
"type": "string", | |
"description": "Optional comma separated list of email addresses to invite to the event. Never provide an email address unless the user specifically provided it" | |
}, | |
}, | |
"required": ["title", "start", "end"] | |
} | |
} | |
}); | |
if (PERPLEXITY_API_TOKEN){ | |
tools.push({ | |
"type": "function", | |
"function": { | |
"name": "answer_question", | |
"description": "Answers a question using Internet search via the Perplexity Sonar API. Use this for information after your knowlege cutoff date, to reserch questions you do not know the answer to, or for local search.", | |
"parameters": { | |
"type": "object", | |
"properties": { | |
"question": { | |
"type": "string", | |
"description": "The question to answer, i.e. 'How many stars are there in the galaxy?'" | |
} | |
}, | |
"required": ["question"] | |
} | |
} | |
}); | |
} | |
return tools; | |
} | |
function createCalendarAppointment(title, start, end, description, location, guests) { | |
options = {} | |
if (description?.length > 0) { | |
options.description = description; | |
} | |
if (location?.length > 0) { | |
options.location = location; | |
} | |
if (guests?.length > 0) { | |
options.guests = guests; | |
options.sendInvites = true; | |
} | |
CalendarApp.getDefaultCalendar().createEvent(title, | |
new Date(start), | |
new Date(end), | |
options); | |
return `Calendar event ${title} has been created.`; | |
} | |
function closeTask(taskId) { | |
const options = { | |
method: 'post', | |
headers: { | |
'Authorization': 'Bearer ' + TODOIST_API_TOKEN | |
} | |
}; | |
UrlFetchApp.fetch(`https://api.todoist.com/rest/v2/tasks/${taskId}/close`, options); | |
return `Task ${taskId} has been closed. You don't need to do anything else and can move to your next step.`; | |
} | |
function addComment(taskId, comment) { | |
const payload = { | |
task_id: taskId, | |
content: comment | |
}; | |
const options = { | |
method: 'post', | |
contentType: 'application/json', | |
headers: { | |
'Authorization': 'Bearer ' + TODOIST_API_TOKEN | |
}, | |
payload: JSON.stringify(payload) | |
}; | |
UrlFetchApp.fetch('https://api.todoist.com/rest/v2/comments', options); | |
} | |
function getComments(taskId) { | |
var response = UrlFetchApp.fetch(`https://api.todoist.com/rest/v2/comments?task_id=${encodeURIComponent(taskId)}`, { | |
headers: { | |
Authorization: 'Bearer ' + TODOIST_API_TOKEN | |
} | |
}); | |
return JSON.parse(response.getContentText()); | |
} | |
function getAiTasks() { | |
var response = UrlFetchApp.fetch(`https://api.todoist.com/rest/v2/tasks?label=${encodeURIComponent(AI_TASK_LABEL)}`, { | |
headers: { | |
Authorization: 'Bearer ' + TODOIST_API_TOKEN | |
} | |
}); | |
return JSON.parse(response.getContentText()); | |
} | |
function getAttachment(url) { | |
var options = { | |
method: "get", | |
headers: { | |
"Authorization": "Bearer " + TODOIST_API_TOKEN | |
} | |
}; | |
var response = UrlFetchApp.fetch(url, options); | |
var fileBlob = response.getBlob(); // Get the file as a Blob | |
var base64String = Utilities.base64Encode(fileBlob.getBytes()); // Convert Blob to Base64 | |
return base64String; | |
} |
To get this working you need API keys from OpenAi and Todoist. Perplexity is optional, if you have a key add it at the top. It only works with tasks that have the right label, ai is the default - you can change this with AI_TASK_LABEL. I initially developed this with o1, but it looks like the tool use was rushed out too quickly and it calls the same tool repeatedly. GPT-4o works well enough and you can test switching out the model by changing OPENAI_MODEL.
Quick configuration guide - create a Google Apps Script project in Google Drive. Paste in the code above and add your API keys and any other desired configuration changes. Go to Project Settings in the right hand navigation and make sure your time zone is correct. Then go to Triggers and schedule the function trigger to run periodically (I use every 5 minutes). You should be done.
Back in Todoist add the ai label to a task (or whatever label you set in the script) and the AI will respond. With the current script there are two use cases - ask it to create an event (it can invite people, add details to the description, etc.), or ask it to research some aspect of the task you're working on. I think this is helpful because it has the full context of the task, and while you're working in Todoist it's useful to store the history there as well.
The point here is to extend the number of tasks that the script can take on. Add new tools for the AI to consider in the getTools() function, and then wire that tool into an implementation in generateAIResponse(). createCalendarAppointment() is a good example of using built in Google services - as well as the calendar it's pretty easy to interact with email, Google docs and many more. I'm planning to add file uploads as well, and will update this post with iterations of the script that add helpful functionality.
OpenAI recommends around 20 tools as the maximum. At that point it might be necessary to break this up into multiple assistants with different tool collections.
Let me know in the comments if you manage to use this to start crossing anything off your list.
Updated 2025-02-17 01:18:
Updated the script to support images and Perplexity.
Image support takes advantage of multimodal input. Any image attachments will be sent as part of the conversation. This uses the large thumbnail in Todoist by default. It supports JPEG, GIF, PNG and WEBP. If a thumbnail is not available it will send the full size image without resizing, depending on how large this might not be accepted by OpenAI.
Perplexity is implemented as a tool (so OpenAI is always called, and it may optionally call out to Perplexity to get more information). This is useful for web search, local search and knowledge past the training cutoff for the OpenAI model. It's optional, if you don't include a Perplexity API key then it just won't be used.
Here's a simple use case - add a photo of a book and ask Todoist to find the URL where you can order it on Kindle.
More Google Apps Script Projects
- Get an email when your security camera sees something new (Apps Script + Cloud Vision)
- Get an email if your site stops being mobile friendly (no longer available)
- Export Google Fit Daily Steps, Weight and Distance to a Google Sheet
- Email Alerts for new Referers in Google Analytics using Apps Script
- Animation of a year of Global Cloud Cover
- Control LIFX WiFi light bulbs from Google Apps Script
- How to backup Google Photos to Google Drive automatically after July 2019 with Apps Script
- Using the Todoist API to set a due date on the Alexa integration to-do list (with Apps Script)
- Automate Google PageSpeed Insights and Core Web Vitals Logging with Apps Script
- Using the Azure Monitor REST API from Google Apps Script
- Monitor page index status with Google Sheets, Apps Script and the Google Search Console API
(Published to the Fediverse as: Adding AI to Todoist with Google Apps Script and OpenAI #code #ai #openai #todoist #ml #appsscript #gas #google #perplexity How to add an AI assistant to Todoist, includes code to respond to tasks and create calendar appointments with gpt-4o. )
Add Comment
All comments are moderated. Your email address is used to display a Gravatar and optionally for notification of new comments and to sign up for the newsletter.