This quick-start guide will show you how to get a simple Todoist UI Extension running in no time.
We will build a simple context menu Extension that displays a button which, upon clicking, displays a notification for the user.Ā
Letās get started! š
Notitie
Node.jsĀ andĀ npmĀ installed yet, please follow theĀ installation guideĀ first.
Let's start by installing some packages (see below):
npm init -y
npm install express nodemon ts-node typescript @doist/ui-extensions-core
npm install @types/express @types/node --save-dev
Create your own integrations service
Now, onto our favorite IDE and let's create anĀ app.tsĀ file that will act as our controller, answering all incoming requests and replying to them.
Note that our controller listens on portĀ 3000Ā and replies to requests to theĀ /processĀ endpoint, but you can change these if you want.
Our newly created controller returns aĀ Doist CardĀ with some text ('Hello, my friend!') and a button with some text ('Click me!') that triggers another action (Action.Submit).
app.ts
import { DoistCard, SubmitAction, TextBlock } from '@doist/ui-extensions-core'
import express, { Request, Response, NextFunction } from 'express'
const port = 3000
const app = express()
const processRequest = async function (
request: Request, response: Response, next: NextFunction
) {
// Prepare the Adaptive Card with the response
// (it's going to be a form with some text and a button)
const card = new DoistCard()
card.addItem(TextBlock.from({
text: 'Hello, my friend!',
}))
card.addAction(
SubmitAction.from({
id: 'Action.Submit',
title: 'Click me!',
style: 'positive',
}),
)
// Send the Adaptive Card to the renderer
response.status(200).json({card: card})
}
app.post('/process', processRequest)
app.listen(port, () => {
console.log(`UI Extension server running on port ${port}.`)
});
Run your integration service
Add a simple script to yourĀ package.json:
npm run dev
Now you're ready to run a single command in your terminal:
npm run dev
Expose your localhost
Now, in order for Todoist to be able to communicate with your integration, we need to expose your service to the internet. There are a couple of tools available to create this kind of tunnel:
ngrok http 3000
For example, if you choose to useĀ ngrok, you'll be running some variation of the following command (we chose to listen on port 3000):
Take note of the URL exposed by your tool of choice, as you'll need it in the next step (i.e.Ā https://my-extension-service).
Create a Todoist App
Finally, we want to create a Todoist App:
- Visit theĀ App Management ConsoleĀ (you'll be prompted to log in if you're not already)
- Click "Create a new App" and insert a name in the "App name" field (i.e. "My first app")
- In theĀ
UI ExtensionsĀ section, click "Add a new UI extension" (it should look like the screenshot on the right):- Give it a name (i.e. "Greet me!")
- Select "Context menu" as the "Extension type" (and "Project" as the "Context type")
- Point "Data exchange endpoint URL" to your service URL followed byĀ
/processĀ (or the endpoint name you chose whenĀ creating your own integrations service). This value in this field might look something likeĀhttps://my-extension-service/process
- In theĀ
InstallationĀ section, click on theĀInstall for meĀ button
Use your UI Extension
Now, for the fun part!
- VisitĀ Todoist.
- Select any of your Todoist projects (or create a new one).
- Click on the context menu icon of that project, select "Integrations" and finally select your UI Extension from the list (i.e. "Greet me!").
Congratulations, you just built and used your first Todoist UI Extension! š
Iterate
Err, our new button doesn't really do much, does it? Well, let's change that.
This time we're going to check theĀ actionTypeĀ (andĀ actionId) that's part of the incoming request:
- If it's the initial request to our UI Extension, then we want to display the simple text and then button.
- If it's a subsequent request (triggered by clicking on the button), then we want to return a notification to the user and terminate the extension flow.
To communicate to the renderer that we want to display a notification, we return a list ofĀ bridges, which are requests to execute a specific action on the client-side. In this case, we choose to return aĀ display.notificationbridgeĀ and aĀ finishedbridge, since we want to display a notification message to the user and also terminate the extension flow.
After making a few code changes, you can test your extension again and be delighted by the notification that pops up once you click on your button:
Code Examples
- You can find the code we just wrote atĀ Getting Started Extension.
- If you want to browse (and run) an integration with more extensive functionality, including receiving and using Todoist API tokens and creating different types of UI extensions, check out ourĀ Lorem Ipsum Extension.
- At Doist, we useĀ NestJSĀ for our UI Extensions, as it allows us to efficiently build new integrations. We haveĀ an open-source SDKĀ with NestJS-specific modules that you can use to develop your own UI Extensions. We have open sourced ourĀ Export to Google Sheets integrationĀ so that you can see how we use this SDK.
- If you're missing a sample for the problem you're trying to solve, pleaseĀ contact usĀ (select "Something else" and then "Integrations Development") to let us know! Alternatively, consider creating a pull request with the sample against ourĀ repo, we're (gladly) accepting contributions.
Next steps
- If you jumped straight to this section, you can go back and review ourĀ IntroductionĀ and learn about the basics of UI Extensions.
- If you want to do more than displaying buttons and notification, learn more aboutĀ Returning UIs and Action bridges.
- If you want to make sure the incoming request is from Todoist (and not a third-party application), check outĀ Security.
- If you want to add other types of UI Extensions (i.e. Composer, Settings), take a look atĀ Adding a UI Extension.
Handling user requests
Client and User Information
Each request made from the Todoist client to your extension will contain information about the client that sent the data, as well as information about the user who triggered the request.
This is called theĀ Data Exchange FormatĀ and establishes what the integration service can expect in the client request:
- Extension Type (
extensionType)Ā - Identifies the type of the extension (context-menu,ĀcomposerĀ orĀsettings). - Context (
context)Ā - Information about the environment and the user that's using the extension. - Action (
action)Ā - Identifies the type of action the user has executed on the client. - Maximum Doist Card Version (
maximumDoistCardVersion)Ā - Identifies the maximumĀ Doist Card versionĀ that the client can support. This can allow your extension to send back differing UI elements based on what the client supports.
Extension type
The extension type depends on where the user has invoked said extension. Supported values are:
- Context MenuĀ (
context-menu) - ComposerĀ (
composer) - SettingsĀ (
settings)
Context Menu
The user can access these integrations from the context menu of a project or task. Developers can specify if their context menu extension should appear in the context menu of either a project or task when creating a newĀ Adding a UI Extension, acting of theĀ Context typeĀ field.
Context menu extensions also provide some additional data, like theĀ sourceĀ in theĀ action.paramsĀ field (seeĀ action).
Composer
The user can access these integrations from the composer extensions menu of a task or comment. Developers can specify if their composer extension should appear in the composer extension menu of either a task or comment when creating a newĀ Adding a UI Extension, acting of theĀ Composer typeĀ field.
Settings
If the extension type isĀ settingsĀ this means it has been triggered from within Todoist's in-app settings. Developers can choose to display there any settings the user can change for the current extension.
A settings extension is great for occasions where there's no other place to put your extension's settings. One example are extensions that don't have any user interface.
While each integration can have more than one context menu and composer extension, it can only have one settings extension.
Context
We send context information with every request to the integration service. It contains:
Theme
This will be the theme of the calling application, eitherĀ lightĀ orĀ dark.
User
It contains the current user that invoked the integration.
A subset of Todoist'sĀ user fields:
short_nametimezoneidlangfirst_namenameemail
Platform
This will be the platform that the client is making the request from and can be used by the integrations to tailor their UI to the platform requesting. This is an optional field and can be eitherĀ desktopĀ orĀ mobile.
Todoist
Todoist-specific context items. These are information about where in Todoist the request was made.
Project
It contains the current project from which the user has triggered the extension. It will be populated only if the user invoked the extension from a project view.
A subset of Todoist'sĀ project fields:
idname
Filter
It contains the current filter from which the user has triggered the extension. It will be populated only if the user invoked the extension from a filter view.
A subset of Todoist'sĀ filter fields:
idname
Label
It contains the current label from which the user has triggered the extension. It will be populated only if the user invoked the extension from a label view.
A subset of Todoist'sĀ label fields:
idname
Additional User Context
Additional context about the user who triggered the request:
isPro:ĀtrueĀ if the user is on a Todoist Pro plan,ĀfalseĀ otherwise
Action
We use the action to signal to the server what kind of interaction does the user expects.
We support the following types of interactions:
Other than the mandatoryĀ actionType, theĀ actionĀ object can contain various different fields, depending on the type of the action.
Initial
TheĀ initialĀ action type tells the server that the user has just opened the integration and expects to see the first screen in the workflow.
In the case of context menu extensions, there will be additional data being sent as part of the initial request, which will live in theĀ paramsĀ field.
Params
| Name | Description |
|---|---|
source String | This will be eitherĀ projectĀ orĀ task. |
url String | This will be the deep link url to the project/task. |
sourceId String | This is the id of the project/task. |
content String | For the following, this will be:
|
contentPlain String | This will be a stripped-down version of the content, with all markdown formatting removed. |
Submit
Whenever a user presses a button, for example, Todoist will send all the fields the user has filled out, checked or interacted with, back to the server, using theĀ Action.SubmitĀ action.
Whenever a user executes an action via theĀ Action.SubmitĀ action on the form; the following will be sent to your integration:
- AllĀ
Input.*Ā fields in the form ofĀ{"inputId": "inputValue"} - AllĀ
dataĀ properties of the Submit element
AnĀ actionIdĀ should be supplied with the request as theĀ submitĀ action on its own may not be very clear as to the action's intended consequence.
Maximum Doist Card Version
This is the maximumĀ version of Doist CardĀ that the requesting client can support. The client will send this field to requests cards that can be successfully displayed on the client itself.
An example of why this is needed is if a user hasn't been able to update to the latest version of Todoist that supports the latest version of Doist Card. In this case, the extension can send back a card that will be supported by that version of the client application. Should the extension not support a lower version of Doist Card, the extension should set theĀ Minimum Doist Card versionĀ when creating their extension in theĀ App Management Console:
Security
In order for your extension to confirm that the request was made from Todoist and not from a potentially malicious actor, each request will include an additional header:Ā x-todoist-hmac-sha256.
This is a SHA256 HMAC generated using the integration's verification token as the encryption key and the whole request payload as the message to be encrypted. The resulting HMAC would be encoded in a base64 string.
To verify that the request is from a trusted source (Todoist) you need to compare it with theĀ verification tokenĀ value of your integration, which you can get from your app settings in theĀ App Management Console.
Here you can see a simplified example of how you can validate this header, where:
verificationTokenĀ (string) is your verification token from App ConsolerequestHeadersĀ (http.IncomingHttpHeaders) are the headers from the incoming requestrequestBodyĀ (Buffer) is the raw body of the incoming request
You can also see this code in action in ourĀ Lorem Ipsum Extension.
Full Client Request Example
The full request that the client makes to the server can look as follows:
Context Menu Extension
{
"context":{
"theme":"light",
"user":{
"email":"janedoe@doist.com",
"timezone":"Europe/Rome",
"id":4939878,
"name":"Jane Doe",
"first_name":"Jane",
"short_name":"Jane",
"lang":"en"
},
"todoist":{
"project":{
"id":"2299753711",
"name":"Test project"
},
"additionalUserContext":{
"isPro":true
}
},
"platform":"desktop"
},
"action":{
"actionType":"initial",
"params":{
"content":"Test project",
"sourceId":"2299753711",
"source":"project",
"url":"https://app.todoist.com/app/project/2299753711",
"contentPlain":"Test project"
}
},
"extensionType":"context-menu",
"maximumDoistCardVersion":0.6
}
Composer Extension
{
"context":{
"theme":"light",
"user":{
"email":"janedoe@doist.com",
"timezone":"Europe/Rome",
"id":4939878,
"name":"Jane Doe",
"first_name":"Jane",
"short_name":"Jane",
"lang":"en"
},
"todoist":{
"project":{
"id":"2299753711",
"name":"Test project"
},
"additionalUserContext":{
"isPro":true
}
},
"platform":"desktop"
},
"action":{
"actionType":"initial"
},
"extensionType":"composer",
"maximumDoistCardVersion":0.6
}
Settings Extension
{
"context":{
"theme":"light",
"user":{
"email":"janedoe@doist.com",
"timezone":"Europe/Rome",
"id":4939878,
"name":"Jane Doe",
"first_name":"Jane",
"short_name":"Jane",
"lang":"en"
},
"platform":"desktop"
},
"action":{
"actionType":"initial"
},
"extensionType":"settings",
"maximumDoistCardVersion":0.6
}
Returning UIs and Action Bridges
When the integration service responds to the client requests, it needs to send several vital pieces of information:
- UI (
card)Ā - TheĀcardĀ is what contains the UI JSON that the Todoist client will render for the user. - Client-side Actions (
bridges)Ā - This field is an array ofĀbridgesĀ (request for the client to perform specific actions on the extensions' behalf, such as inject text into the composer or display a notification). The bridges will be executed in the order in which they appear in the array.
Note that both properties (cardĀ andĀ bridges) can present at the same time, but at least one must always be filled out.
UI
In most cases, the extension will instruct the client to display a UI rendered as aĀ Doist Card.
{
"card": {
"type": "AdaptiveCard",
"body": [{
"text": "Welcome, my friend!",
"type": "TextBlock"
}],
{...}
}
}
Client-side Actions
In some cases, the extension also need to ask the client to execute actions within the app itself. These requests for actions are calledĀ bridges.
Currently, we support the following bridge action types (bridgeActionType):
- Display Notification (
display.notification) - Append Text to Composer (
composer.append) - Trigger a Todoist sync (
request.sync) - Extension has finished (
finished)
An extension can send multiple bridges as part of the response and the client will execute each bridge in order. See the example on the right of an extension that is instructing the client to add text to the composer and then to close the extension:
Display Notification
Extensions can issue requests to display aĀ lightweight notificationĀ as an alternative to a full card. This is a great choice for quick messages. In this case, the response will include the following property:
notification:Ā notificationĀ that will be displayed to the user.
Notification
Properties
| Name | Description |
|---|---|
text String | This is the text to appear in the notification. This should be plain text, Markdown is not supported here. |
type String | This will be eitherĀ info,Ā successĀ orĀ error. |
actionUrl String | (Optional) This is the action URL that will be opened ifĀ actionTextĀ is clicked. |
actionText String | (Optional) This is the text to be displayed for the action. This should be plain text, Markdown is not supported here. |
Note: For the notification to display an action,Ā bothactionUrlĀ andĀ actionTextĀ must be provided.
Append to Composer
Upon receiving aĀ composer.appendĀ action bridge, the client will append the specified text into the Todoist text composer:
text: text to append at the end of the current message
Request Todoist Sync
When the extension issues aĀ request.sync, the client will perform a Todoist sync. In this case, the supported properties are:
onSuccessNotification:Ā notificationĀ that will be displayed to the user in case the Todoist sync is successfulonErrorNotification:Ā notificationĀ that will be displayed to the user in case the Todoist sync is not successful
Extension has finished
When theĀ finishedĀ bridge is sent back, it's a way for the extension to tell to the client that the extension has finished its lifecycle and it should be closed.
Full Server Response Example
{
"card":{
"$schema":"http://adaptivecards.io/schemas/adaptive-card.json",
"adaptiveCardistVersion":"0.6",
"body":[
{
"columns":[
{
"items":[
{
"id":"title",
"size":"large",
"text":"Export **Test project**",
"type":"TextBlock"
}
],
"type":"Column",
"verticalContentAlignment":"center",
"width":"stretch"
},
{
"items":[
{
"columns":[
{
"items":[
{
"altText":"View Settings",
"height":"24px",
"selectAction":{
"id":"Action.Settings",
"type":"Action.Submit"
},
"type":"Image",
"url":"https://td-sheets.todoist.net/images/shared/Settings-light.png",
"width":"24px"
}
],
"type":"Column",
"width":"auto"
}
],
"type":"ColumnSet"
}
],
"type":"Column",
"width":"auto"
}
],
"spacing":"medium",
"type":"ColumnSet"
},
{
"items":[
{
"id":"options-header",
"isSubtle":true,
"text":"Choose which fields you want to export.",
"type":"TextBlock"
},
{
"columns":[
{
"items":[
{
"id":"Input.completed",
"title":"Is completed",
"type":"Input.Toggle",
"value":"true"
},
{
"id":"Input.due",
"title":"Due date",
"type":"Input.Toggle",
"value":"true"
},
{
"id":"Input.priority",
"title":"Priority",
"type":"Input.Toggle",
"value":"true"
},
{
"id":"Input.description",
"title":"Description",
"type":"Input.Toggle",
"value":"true"
}
],
"type":"Column",
"width":"stretch"
},
{
"items":[
{
"id":"Input.parentTask",
"title":"Parent task",
"type":"Input.Toggle",
"value":"true"
},
{
"id":"Input.section",
"title":"Section",
"type":"Input.Toggle",
"value":"true"
},
{
"id":"Input.assignee",
"title":"Assignee",
"type":"Input.Toggle",
"value":"true"
},
{
"id":"Input.createdDate",
"title":"Created date",
"type":"Input.Toggle",
"value":"true"
}
],
"type":"Column",
"width":"stretch"
}
],
"type":"ColumnSet"
},
{
"isSubtle":true,
"text":"The following fields are always exported: Task Id, Task Name, Section Id, and Parent Task Id.",
"type":"TextBlock",
"wrap":true
}
],
"spacing":"medium",
"type":"Container"
},
{
"columns":[
{
"items":[
{
"actions":[
{
"id":"Action.Export",
"style":"positive",
"title":"Export",
"type":"Action.Submit"
}
],
"type":"ActionSet"
}
],
"type":"Column",
"width":"auto"
}
],
"horizontalAlignment":"right",
"spacing":"medium",
"type":"ColumnSet"
}
],
"doistCardVersion":"0.6",
"type":"AdaptiveCard",
"version":"1.4"
}
}
Adding a UI Extension
Extensions can be added to integrations in theĀ App Management Console. An integration can have multiple UI extensions added to it. More specifically, multiple context menu extensions and composer extensions are allowed, however an integration can only haveĀ oneĀ settings extension.
Users cannot show/hide specific extensions for an integration.
To add an extension, you need to create a new App first:
Then from the UI Extensions section, click on "UI Extensions":
Context Menu and Composer Extensions Menu
If you're looking to create an extension that appears in theĀ context menu or composer extensions menu, click the "Add a new UI extension" button. Then, enter the details of your extension.
It is recommended to have an extension name with fewer than 29 characters to avoid truncation within Todoist's context menu.
Once you create an extension, you can also add an icon to it. This icon will be the image that appears in the context menu or composer extensions menu andĀ notĀ the integration's image (as you can have multiple extensions, you might want different icons for each of the extensions).
Properties
| Name | Description |
|---|---|
| Name | The name for your UI Extension, it will appear in the context menu or composer extensions menu. |
| Description | This will appear as the sub-text in the composer extensions menu if the extension is a composer extension. |
| Extension type | EitherĀ ComposerĀ orĀ Context menu. |
| Context type | Displayed only if Extension type isĀ Context menu. EitherĀ ProjectĀ orĀ Task. |
| Composer type | Displayed only if Extension type isĀ Composer. EitherĀ TaskĀ orĀ Comment. |
| Data Exchange Format version | The version of the Data Exchange Format your extension accepts. SeeĀ ref. |
| Data exchange endpoint URL | The url for your integration service and where the requests will be sent from Todoist. |
| Minimum Doist Card version | The minimum Doist Card version your extension supports. For example, some older mobile clients might not support all the features required by the latest Doist Card version and will leverage this field to decide if the extension should be displayed for the user or not. |
Settings Extensions
Use the "Add a settings extension" button to add a settings extension. Remember: an integration can only haveĀ oneĀ of these.
Properties
| Name | Description |
|---|---|
| Data Exchange Format version | The version of the Data Exchange Format your extension accepts. SeeĀ ref. |
| Data exchange endpoint URL | The url for your integration service and where the requests will be sent from Todoist. |
| Minimum Doist Card version | The minimum Doist Card version your extension supports. For example, some older mobile clients might not support all the features required by the latest Doist Card version and will leverage this field to decide if the extension should be displayed for the user or not. |
Short-lived Tokens
If your integration service has a need to call the TodoistĀ REST APIĀ orĀ Sync APIĀ during the UI Extension lifecycle, you don't need to implement the OAuth flow in your extension.
Instead, you can configure Todoist to include a short-lived access token in the requests sent to your integration service, which you can then use in the same way you would use an access token obtained through theĀ OAuth flow.
These tokens have a limited lifetime, on the span of a few minutes.
If you wish to use the Todoist API to query or manipulate user data outside the UI Extension lifecycle, you must implement the OAuth flow into your extension.
Enabling Short-lived Tokens
You can enable these tokens by selecting the authorization scopes needed by your integration in the UI Extensions section of your app in theĀ App Console:
Once enabled, users who install your integration (and users who use your integration for the first time after scopes are changed) will be presented with a consent flow:
Once the user has consented to those scopes, the token will be included in theĀ x-todoist-apptokenĀ header in all requests sent to your integration service.
Get in touch
If you still have questions about UI extensions or want to learn more,Ā get in touch with us. We ā Keita, Melis, Marco, and any of our other teammates ā are more than happy to help!