Design your connector¶
Connectors don't include traditional user interface elements like dialogs or sidebars. Instead your building blocks are reached through each product's existing interfaces.
- Go — Go calls them while working through a task.
- Docs — Users reach them from places like the formula editor.
However there are still many subtle design choices to make when building your connector, and they can have a real impact on usability. This page aims to provide the guidance you need to create a connector that meets the needs and expectations of Superhuman users.
General guidance¶
No matter what kind of connector you are building, there are some basic rules to keep in mind.
Build building blocks¶
Unlike other types of integrations, a connector doesn't prescribe an exact end-to-end experience. Instead it provides a new set of building blocks that get combined in ways you don't control.
- Go — Go chains them together to satisfy a request.
- Docs — Users assemble them into their own docs.
These building blocks need to provide sufficient flexibility so that they can be combined in novel and bespoke ways.
- Prefer parameters over hard-coding specific patterns.
- Return structured data, so the results can be chained together.
Don't
TasksDueWithin7Days() =>
<ul>
<li>Send out TPS report - Monday</li>
<li>Complete training - Wednesday</li>
<li>Organize team lunch - Friday</li>
</ul>
Do
Tasks(dueWithin: Duration(7)) =>
[
{
description: "Send out TPS report",
due: "2023-02-20",
}
// etc...
]
Design around use cases¶
Build your building blocks around the things people actually do with the product. A single building block should accomplish a task someone would recognize, even when that takes several API calls behind the scenes. This pays off in both products.
- Go — Go has fewer opportunities to chain calls together incorrectly.
- Docs — A user reaches for one formula instead of assembling three.
An API is organized around how a service stores its data, so a thin wrapper with one building block per endpoint is usually the wrong shape. Think of the connector as an extension of the product's user experience, focussing on the key data and tasks that users care about.
- Combine the API calls that make up a single task into one building block.
- Name building blocks after what someone is trying to accomplish, not the endpoint behind them.
- Avoid using technical jargon when naming building blocks, parameters, or outputs.
- Hide implementation details, like API versions and payload formats.
Don't
CreateCustomerRecord("Acme")
CreateOwnerRecord("v2",
"{\"email\": \"ada@acme.com\"}")
LinkOwnerToCustomer(customerId, ownerId)
Do
AddCustomer("Acme", "ada@acme.com")
Less is more¶
Developers love to have expansive APIs that provide complete access to all features, but too much choice causes problems in both products.
- Go — It's harder for Go to pick the right building block for a task.
- Docs — Users are overwhelmed by the number of options.
When designing a connector, focus on the 20% of functionality that will meet the needs of 80% of your users. Omit more advanced options or features at first, addressing them if/when there is sufficient demand.
- Omit obscure advanced options, preferring instead sensible defaults that work well in the majority of cases.
- Put the most important parameters first, and use optional parameters when a value is not strictly required.
Don't
AddTask(project, task, labels, reccurence,
workflow, dueDate)
Do
AddTask(task, project, [dueDate])
Use simple names¶
When building a connector you don't need to worry about name collisions, and accessibility is more important than completeness or accuracy. When choosing a name, prefer simple nouns or verbs and remove any extraneous detail.
- Don't include the connector or company name.
- Avoid unnecessary detail in names, unless required to distinguish them.
- Use names that sound more like ordinary speech.
- Prefer single nouns or verbs when feasible.
Don't
AcmeTasksListAllTasks()
AcmeTasksCreateFromScannedImageUpload()
AcmeTasksSetAssignee()
Do
Tasks()
AddFromPhoto()
Reassign()
You can find more best practices for naming building blocks in the guides for formulas, actions, and sync tables.
Write clear descriptions¶
Every building block has a description field, and the value you set there has an impact on its usability.
- Go — The LLM reads descriptions to decide which building block to call and how to fill in its parameters.
- Docs — They become the documentation users see while working with your connector.
Setting detailed descriptions has benefits in both cases, and it's worth investing time in writing good ones.
- Say what the building block does and when someone would reach for it, not how it's implemented.
- Add descriptions for parameters and schemas as well, making it clear what data needs to be passed in and what data is returned.
Don't
name: "Tasks",
description: "Tasks",
Do
name: "Tasks",
description: `
Lists the tasks in a project, optionally
limited to those due within a given time
frame.
`,
API Integration¶
A common use case for connectors is integrating with another application or service using their API. While each integration is unique, there are certain patterns and conventions that can be useful to understand. This section includes some tips for designing a connector around an existing API.
Check for an MCP server¶
Go only
MCP servers are only supported in Go.
Before designing tools by hand, check whether the service already publishes a hosted MCP server. Connecting to one gives Go that service's tools without you defining any of them, and the service keeps them current as their API changes. It's the shortest path to a working integration.
Build the tools yourself when there's no MCP server available, when the connector also needs to work in Docs, or when you want to expose a smaller and more focused set of tools than the server provides. The rest of this section covers that case.
Select collections¶
Most REST APIs are organized into collections, usually corresponding specific types of items in the application. An API can contain dozens of collections, but as per the general guidance above it's best to start with the handful of core ones that are most valuable to users.
Example: Todoist
The Todoist API includes collections for Projects, Sections, Tasks, Comments, and Labels. While a power user may want to leverage all of that information, for most users Projects and Tasks are the core entities they'll want to work with.
Design the schema¶
Examine the data returned for each item in the collection and determine what to expose. Select the fields most important to users and start there. You can always add more fields later without breaking anything.
When designing your schema, select user-friendly names for your properties. The field in the API may use technical terminology or refer to an older name no longer in use by the product.
Example: Todoist task schema
The Todoist API returns up to 20 fields for a task, but for most use cases only a few are required. Additionally the name "content" is replaced with "name".
{
"user_id": "2671355",
"id": "6X7rfFVPjhvv84XG",
"project_id": "6Xx8rMQZ5Wc9CqcH",
"section_id": null,
"parent_id": null,
"added_by_uid": "2671355",
"assigned_by_uid": null,
"responsible_uid": null,
"labels": ["Food", "Shopping"],
"deadline": null,
"duration": null,
"is_collapsed": false,
"checked": false,
"is_deleted": false,
"added_at": "2019-12-11T22:36:50.000000Z",
"completed_at": null,
"completed_by_uid": null,
"updated_at": "2019-12-11T22:36:50.000000Z",
"due": {
"date": "2016-09-01",
"timezone": null,
"string": "tomorrow at 12",
"lang": "en",
"is_recurring": false
},
"priority": 1,
"child_order": 1,
"content": "Buy Milk",
"description": "",
"note_count": 10,
"day_order": -1,
"completed_count": 0,
"postponed_count": 0
}
const TaskSchema = sdk.makeObjectSchema({
properties: {
name: {
description: "The name of the task.",
type: sdk.ValueType.String,
fromKey: "content",
},
description: {
description: "A description of the task.",
type: sdk.ValueType.String,
},
url: {
description: "A link to the task.",
type: sdk.ValueType.String,
codaType: sdk.ValueHintType.Url,
},
id: {
description: "The ID of the task.",
type: sdk.ValueType.String,
},
},
displayProperty: "name",
idProperty: "id",
featuredProperties: ["description", "url"],
});
Add building blocks¶
For each collection, add a set of building blocks that allow users to work with them. Which ones to prioritize depends on where your connector runs.
- Go — Start with formulas and actions, which become tools Go can call directly. Sync tables help when Go needs to search across a whole collection, but they're a larger investment.
- Docs — Start with a sync table, which puts the whole collection in front of users, then add formulas and actions around it.
The exact set may vary from collection to collection, so use the guidance below as a starting point.
Requirements
- The API has an endpoint for retrieving a specific item by ID (ex:
GET /tasks/123). - The ID of an item is user-visible (or can be obtained from a user-visible URL).
A "getter" formula allows users to retrieve the details of a specific item, which can then be composed with other formulas or tables.
- The formula should take the ID and/or URL as a parameter, and return an object matching the defined schema.
Example: Todoist Task() formula
GET https://api.todoist.com/api/v1/tasks/<taskId>
pack.addFormula({
name: "Task",
description: "Gets a Todoist task by URL",
parameters: [
sdk.makeParameter({
type: sdk.ParameterType.String,
name: "url",
description: "The URL of the task",
}),
],
resultType: sdk.ValueType.Object,
schema: TaskSchema,
execute: async function ([url], context) {
let taskId = extractTaskId(url);
let response = await context.fetcher.fetch({
url: "https://api.todoist.com/api/v1/tasks/" + taskId,
method: "GET",
});
let task = response.body;
return {
...task,
url: "https://app.todoist.com/app/task/" + task.id,
};
},
});
const TaskUrlPatterns: RegExp[] = [
// The current URL format, where the ID follows a slug of the task name.
new RegExp("^https://app.todoist.com/app/task/(?:.*-)?([0-9a-zA-Z]+)$"),
// Legacy URL formats, which only used numeric IDs.
new RegExp("^https://todoist.com/app/task/([0-9]+)$"),
new RegExp("^https://todoist.com/app/project/[0-9]+/task/([0-9]+)$"),
new RegExp("^https://todoist.com/showTask\\?id=([0-9]+)"),
];
function extractTaskId(taskUrl: string) {
for (let pattern of TaskUrlPatterns) {
let matches = taskUrl.match(pattern);
if (matches && matches[1]) {
return matches[1];
}
}
throw new sdk.UserVisibleError("Invalid task URL: " + taskUrl);
}
Requirements
- The API has a endpoints for manipulating the collection, for instance:
- Creating an item (ex:
POST /tasks) - Updating an item (ex:
PUT /tasks/123) - Deleting an item (ex:
DELETE /tasks/123) - Performing a custom action (ex:
POST /tasks/123:notify)
- Creating an item (ex:
An action formula lets items be updated from within Superhuman. Any API calls that have side effects (change the state of the app being integrated with) should be exposed as action formulas, since regular formulas can be re-executed at any time and aren't gated behind a user confirmation.
- When creating or updating items, use optional parameters to capture the values for individual fields.
- In addition to a generic update action, consider adding streamlined action formulas for common tasks (ex:
Reassign,ChangeAddress, etc.).
Example: Todoist AddTask() action formula
POST https://api.todoist.com/api/v1/tasks
{
"content": "Buy milk"
}
pack.addFormula({
name: "AddTask",
description: "Add a new task.",
parameters: [
sdk.makeParameter({
type: sdk.ParameterType.String,
name: "name",
description: "The name of the task.",
}),
],
resultType: sdk.ValueType.String,
isAction: true,
execute: async function ([name], context) {
let response = await context.fetcher.fetch({
url: "https://api.todoist.com/api/v1/tasks",
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
content: name,
}),
});
// Return values are optional but recommended. Returning a URL or other
// unique identifier is recommended when creating a new entity.
return "https://app.todoist.com/app/task/" + response.body.id;
},
});
Requirements
- The API has an endpoint for retrieving all the items in the collection (ex:
GET /tasks).
A sync table brings a whole collection into Superhuman and keeps it up to date. In Go the records also need to be indexed before they can be searched.
- If the API endpoint support filtering the results, consider exposing those as parameters on the sync table to allow for faster, more targeted syncs.
- If the API paginates the results, use continuations to spread the requests over multiple executions and avoid timeouts.
Example: Todoist Tasks sync table
GET https://api.todoist.com/api/v1/tasks
GET https://api.todoist.com/api/v1/tasks/filter?query=<filter string>
pack.addSyncTable({
name: "Tasks",
schema: TaskSchema,
identityName: "Task",
formula: {
name: "SyncTasks",
description: "Sync tasks",
parameters: [
sdk.makeParameter({
type: sdk.ParameterType.String,
name: "filter",
description: "A supported filter string. See the Todoist help center.",
optional: true,
}),
],
execute: async function ([filter], context) {
let url = "https://api.todoist.com/api/v1/tasks";
if (filter) {
// Filter queries are handled by a separate endpoint.
url = sdk.withQueryParams(
"https://api.todoist.com/api/v1/tasks/filter",
{ query: filter },
);
}
let response = await context.fetcher.fetch({
method: "GET",
url: url,
});
let results = [];
for (let task of response.body.results) {
results.push({
name: task.content,
description: task.description,
url: "https://app.todoist.com/app/task/" + task.id,
id: task.id,
});
}
return {
result: results,
};
},
},
});