Skip to Content
DocsDevelopersYour First Connector

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 install run
  • 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_key

This 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.md

The 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 Authorization header. Even though we declared auth_type: 'api_key', the Todoist API expects it in the Authorization header (this is API-specific).
  • Error handling: We check for 401, 429, and other errors, throwing FeelrError with appropriate codes and hints.
  • Normalization: We map Todoist’s response fields to flat snake_case objects.
  • Pagination: We use ctx.cursor to track the offset and return meta.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.ts

Step 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 test

You 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 typecheck

Step 8: Submit Your PR

Once your connector is working and tests pass:

  1. Create a branch: git checkout -b feat/connector-todoist
  2. Commit your changes: git add connectors/todoist && git commit -m "feat(todoist): add Todoist connector with tasks.list and tasks.create"
  3. Push and open a PR: Reference the connector request issue if one exists
  4. 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 @cloudflare imports)
    • Action descriptions are 50-100 tokens and agent-optimized
    • Response data uses flat JSON with snake_case keys
    • Tests exist and pass
    • Error handling uses FeelrError

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/, and connectors/discord/ for real-world examples
  • Open a connector request: Suggest another API to integrate via our issue templates 
Last updated on