Your First Connector
This tutorial walks through creating a complete Feelr connector from scratch. By the end, you will have a working Todoist connector with two actions (tasks.list and tasks.create), tests, and everything needed to submit a pull request.
Prerequisites:
- The Feelr monorepo cloned and
pnpm installrun - A Todoist API token (free account works)
- Basic TypeScript knowledge
Step 1: Scaffold the Connector
Use the built-in scaffolding command to create the connector structure:
pnpm create-connector todoist --auth api_keyThis creates connectors/todoist/ with:
connectors/todoist/
src/
index.ts # ConnectorDefinition
actions/
items.ts # Example LIST action (we'll replace this)
item-get.ts # Example GET action (we'll replace this)
item-create.ts # Example CREATE action (we'll replace this)
__tests__/
actions.test.ts # Test examples (we'll update this)
package.json # @feelr/connector-todoist
tsconfig.json
README.mdThe scaffolding command also runs pnpm install to register the new workspace package.
Step 2: Explore the Generated Files
Open connectors/todoist/src/index.ts. The scaffolding has already set the connector name, auth type, and display name:
import type { ConnectorDefinition } from '@feelr/connector-sdk'
import { itemsList } from './actions/items'
import { itemGet } from './actions/item-get'
import { itemCreate } from './actions/item-create'
export const todoistConnector: ConnectorDefinition = {
name: 'todoist',
display_name: 'Todoist Connector',
version: '0.1.0',
auth_type: 'api_key',
actions: {
'items.list': itemsList,
'item.get': itemGet,
'item.create': itemCreate,
},
}The generated package.json has @feelr/connector-todoist as its name and @feelr/connector-sdk as the only runtime dependency.
Step 3: Implement Your First Action
Replace the example items.ts with a real tasks.list action. Create connectors/todoist/src/actions/tasks-list.ts:
import type { ActionDefinition, ActionContext, ActionResult } from '@feelr/connector-sdk'
import { FeelrError } from '@feelr/connector-sdk'
export const tasksList: ActionDefinition = {
name: 'tasks.list',
description: 'Lists active tasks with optional project filtering and pagination. Returns array of { id, content, description, is_completed, priority, created_at } objects.',
params: [
{
name: 'project_id',
type: 'string',
required: false,
description: 'Filter tasks by project ID',
},
{
name: 'per_page',
type: 'number',
required: false,
description: 'Number of tasks to return (max 100)',
default: 20,
},
],
returns: 'list',
handler: async (ctx: ActionContext): Promise<ActionResult> => {
const url = new URL('https://api.todoist.com/rest/v2/tasks')
if (ctx.params.project_id) {
url.searchParams.set('project_id', String(ctx.params.project_id))
}
// Todoist uses cursor-based pagination via the Sync API,
// but the REST API uses simple limit/offset
const limit = Number(ctx.params.per_page) || 20
url.searchParams.set('limit', String(limit))
if (ctx.cursor) {
url.searchParams.set('offset', ctx.cursor)
}
const response = await ctx.fetch(url.toString(), {
headers: {
Authorization: `Bearer ${ctx.credential}`,
},
})
if (response.status === 401) {
throw new FeelrError('AUTH_INVALID', {
message: 'Invalid Todoist API token',
hint: 'auth',
status: 401,
})
}
if (response.status === 429) {
throw new FeelrError('RATE_LIMITED', {
message: 'Todoist rate limit exceeded',
hint: 'retry',
status: 429,
detail: 'Retry after 60 seconds',
})
}
if (!response.ok) {
throw new FeelrError('UPSTREAM_ERROR', {
message: `Todoist API error: ${response.status}`,
hint: 'retry',
status: 502,
detail: await response.text(),
})
}
const raw = await response.json() as any[]
// Normalize: flatten and snake_case
const data = raw.map((task: any) => ({
id: task.id,
content: task.content,
description: task.description || '',
is_completed: task.is_completed,
priority: task.priority,
project_id: task.project_id,
created_at: task.created_at,
}))
// Todoist REST API returns all matching tasks; estimate pagination
const hasMore = data.length === limit
const nextOffset = hasMore ? String((Number(ctx.cursor) || 0) + limit) : undefined
return {
data,
meta: {
has_more: hasMore,
cursor: nextOffset,
},
raw,
}
},
}Key things to notice:
- Auth: Todoist uses an API key sent as a Bearer token in the
Authorizationheader. Even though we declaredauth_type: 'api_key', the Todoist API expects it in theAuthorizationheader (this is API-specific). - Error handling: We check for 401, 429, and other errors, throwing
FeelrErrorwith appropriate codes and hints. - Normalization: We map Todoist’s response fields to flat
snake_caseobjects. - Pagination: We use
ctx.cursorto track the offset and returnmeta.has_more/meta.cursor.
Step 4: Add a Second Action
Create connectors/todoist/src/actions/tasks-create.ts:
import type { ActionDefinition, ActionContext, ActionResult } from '@feelr/connector-sdk'
import { FeelrError } from '@feelr/connector-sdk'
export const tasksCreate: ActionDefinition = {
name: 'tasks.create',
description: 'Creates a new task in Todoist. Requires content (task text). Optionally set project_id, priority (1-4), and due_string. Returns the created { id, content, is_completed, priority, created_at } object.',
params: [
{
name: 'content',
type: 'string',
required: true,
description: 'Task text content',
},
{
name: 'project_id',
type: 'string',
required: false,
description: 'Project to add the task to (defaults to Inbox)',
},
{
name: 'priority',
type: 'number',
required: false,
description: 'Priority level: 1 (normal) to 4 (urgent)',
default: 1,
},
{
name: 'due_string',
type: 'string',
required: false,
description: 'Natural language due date (e.g., "tomorrow", "every monday")',
},
],
returns: 'single',
handler: async (ctx: ActionContext): Promise<ActionResult> => {
const body: Record<string, unknown> = {
content: ctx.params.content,
}
if (ctx.params.project_id) body.project_id = ctx.params.project_id
if (ctx.params.priority) body.priority = ctx.params.priority
if (ctx.params.due_string) body.due_string = ctx.params.due_string
const response = await ctx.fetch('https://api.todoist.com/rest/v2/tasks', {
method: 'POST',
headers: {
Authorization: `Bearer ${ctx.credential}`,
'Content-Type': 'application/json',
},
body: JSON.stringify(body),
})
if (response.status === 401) {
throw new FeelrError('AUTH_INVALID', {
message: 'Invalid Todoist API token',
hint: 'auth',
status: 401,
})
}
if (!response.ok) {
throw new FeelrError('UPSTREAM_ERROR', {
message: `Todoist API error: ${response.status}`,
hint: 'retry',
status: 502,
detail: await response.text(),
})
}
const raw = await response.json() as any
return {
data: {
id: raw.id,
content: raw.content,
description: raw.description || '',
is_completed: raw.is_completed,
priority: raw.priority,
project_id: raw.project_id,
created_at: raw.created_at,
},
raw,
}
},
}This follows the CREATE pattern: POST request, required content parameter, returns the created resource as a single object.
Step 5: Update the Connector Definition
Now update connectors/todoist/src/index.ts to use your new actions:
import type { ConnectorDefinition } from '@feelr/connector-sdk'
import { tasksList } from './actions/tasks-list'
import { tasksCreate } from './actions/tasks-create'
export const todoistConnector: ConnectorDefinition = {
name: 'todoist',
display_name: 'Todoist',
version: '0.1.0',
auth_type: 'api_key',
actions: {
'tasks.list': tasksList,
'tasks.create': tasksCreate,
},
}Clean up the scaffolded example files you no longer need:
rm connectors/todoist/src/actions/items.ts
rm connectors/todoist/src/actions/item-get.ts
rm connectors/todoist/src/actions/item-create.tsStep 6: Write Tests
Create connectors/todoist/src/__tests__/contract.test.ts for SDK compliance tests:
import { validateConnector } from '@feelr/connector-test-utils'
import { todoistConnector } from '../index'
// Auto-generates contract tests: valid name, display_name, version,
// auth_type, action names, descriptions, params, handler functions
validateConnector(todoistConnector)Then create connectors/todoist/src/__tests__/actions.test.ts for action-specific tests:
import { describe, it, expect, vi } from 'vitest'
import type { ActionContext } from '@feelr/connector-sdk'
import { tasksList } from '../actions/tasks-list'
import { tasksCreate } from '../actions/tasks-create'
function createMockContext(
params: Record<string, unknown> = {},
overrides: Partial<ActionContext> = {},
): ActionContext {
return {
params,
fetch: vi.fn(),
credential: 'test-api-token',
cursor: undefined,
...overrides,
}
}
describe('tasks.list', () => {
it('returns a data array with pagination metadata', async () => {
const mockTasks = [
{ id: '1', content: 'Buy milk', description: '', is_completed: false, priority: 1, project_id: '100', created_at: '2024-01-15T10:00:00Z' },
{ id: '2', content: 'Write docs', description: 'SDK reference', is_completed: false, priority: 2, project_id: '100', created_at: '2024-01-16T10:00:00Z' },
]
const mockFetch = vi.fn().mockResolvedValue({
ok: true,
status: 200,
json: async () => mockTasks,
})
const ctx = createMockContext({ per_page: 20 }, { fetch: mockFetch as any })
const result = await tasksList.handler(ctx)
expect(Array.isArray(result.data)).toBe(true)
expect(result.data).toHaveLength(2)
expect((result.data as any[])[0]).toEqual({
id: '1',
content: 'Buy milk',
description: '',
is_completed: false,
priority: 1,
project_id: '100',
created_at: '2024-01-15T10:00:00Z',
})
expect(result.meta?.has_more).toBe(false)
expect(result.raw).toEqual(mockTasks)
})
it('passes project_id filter to the API', async () => {
const mockFetch = vi.fn().mockResolvedValue({
ok: true,
status: 200,
json: async () => [],
})
const ctx = createMockContext(
{ project_id: '12345', per_page: 10 },
{ fetch: mockFetch as any },
)
await tasksList.handler(ctx)
const calledUrl = mockFetch.mock.calls[0][0]
expect(calledUrl).toContain('project_id=12345')
})
it('throws AUTH_INVALID on 401 response', async () => {
const mockFetch = vi.fn().mockResolvedValue({
ok: false,
status: 401,
})
const ctx = createMockContext({}, { fetch: mockFetch as any })
await expect(tasksList.handler(ctx)).rejects.toThrow('Invalid Todoist API token')
})
})
describe('tasks.create', () => {
it('creates a task and returns the normalized result', async () => {
const mockCreated = {
id: '99',
content: 'New task',
description: '',
is_completed: false,
priority: 1,
project_id: '100',
created_at: '2024-01-20T12:00:00Z',
}
const mockFetch = vi.fn().mockResolvedValue({
ok: true,
status: 200,
json: async () => mockCreated,
})
const ctx = createMockContext(
{ content: 'New task' },
{ fetch: mockFetch as any },
)
const result = await tasksCreate.handler(ctx)
expect(result.data).toEqual({
id: '99',
content: 'New task',
description: '',
is_completed: false,
priority: 1,
project_id: '100',
created_at: '2024-01-20T12:00:00Z',
})
// Verify POST was called with correct body
const fetchCall = mockFetch.mock.calls[0]
expect(fetchCall[1].method).toBe('POST')
expect(JSON.parse(fetchCall[1].body)).toEqual({ content: 'New task' })
})
it('sends optional parameters in the request body', async () => {
const mockFetch = vi.fn().mockResolvedValue({
ok: true,
status: 200,
json: async () => ({ id: '99', content: 'Task', description: '', is_completed: false, priority: 4, project_id: '200', created_at: '2024-01-20T12:00:00Z' }),
})
const ctx = createMockContext(
{ content: 'Task', project_id: '200', priority: 4, due_string: 'tomorrow' },
{ fetch: mockFetch as any },
)
await tasksCreate.handler(ctx)
const body = JSON.parse(mockFetch.mock.calls[0][1].body)
expect(body.project_id).toBe('200')
expect(body.priority).toBe(4)
expect(body.due_string).toBe('tomorrow')
})
})Step 7: Run and Verify
Run your tests:
pnpm --filter @feelr/connector-todoist testYou should see all tests passing — both the contract tests from validateConnector() and your action-specific tests.
Then verify everything compiles across the monorepo:
pnpm turbo typecheckStep 8: Submit Your PR
Once your connector is working and tests pass:
- Create a branch:
git checkout -b feat/connector-todoist - Commit your changes:
git add connectors/todoist && git commit -m "feat(todoist): add Todoist connector with tasks.list and tasks.create" - Push and open a PR: Reference the connector request issue if one exists
- Fill out the PR checklist — the SDK compliance items are:
- Only runtime dependency is
@feelr/connector-sdk - All actions use Web Standard APIs (no
node:or@cloudflareimports) - Action descriptions are 50-100 tokens and agent-optimized
- Response data uses flat JSON with
snake_casekeys - Tests exist and pass
- Error handling uses
FeelrError
- Only runtime dependency is
See CONTRIBUTING.md for full commit conventions and PR guidelines.
Next Steps
- Add more actions:
tasks.get,tasks.update,tasks.delete,projects.list - Read the SDK Reference: Full documentation of every type and interface
- Browse existing connectors: Look at
connectors/github/,connectors/slack/,connectors/stripe/, andconnectors/discord/for real-world examples - Open a connector request: Suggest another API to integrate via our issue templates