Adapter Interface

August 22, 2026 · View on GitHub

An ATS adapter wraps a storage backend (TickTick, Notion, Obsidian, plain markdown, Things, Google Tasks, …) so the ATS Core can list / read / write / link items without knowing the underlying system.

This document defines the contract.

Required methods

interface KnowledgeAdapter {
  listProjects(): Promise<Project[]>
  listTasksInProject(projectId: string): Promise<Task[]>
  getTask(projectId: string, taskId: string): Promise<Task>
  createTask(input: TaskInput): Promise<Task>
  updateTask(projectId: string, taskId: string, patch: TaskPatch): Promise<Task>
  urlFor(ref: { projectId: string, taskId: string }): string
}

Payload shapes

type Project = {
  id: string                     // adapter-stable; used as input to listTasksInProject
  name: string                   // human-visible
  kind?: 'tasks' | 'notes'       // optional hint; some adapters distinguish
  raw?: any                      // adapter-internal extras, opaque to Core
}

type Task = {
  id: string                     // adapter-stable; used as input to getTask / updateTask
  title: string
  content: string                // markdown body, may be empty
  projectId: string              // matches Project.id
  tags: string[]                 // empty array if adapter has no tags
  dueDate?: string               // ISO 8601, optional
  modifiedTime: string           // ISO 8601 — used for cache invalidation
  links?: TaskLink[]             // adapter-native read-only relationships
  raw?: any                      // adapter-internal extras
}

type TaskInput = {
  title: string
  content?: string
  projectId?: string             // adapter may default to inbox/root
  tags?: string[]
  dueDate?: string
}

type TaskPatch = Partial<TaskInput> & { title?: string }

Method contracts

listProjects() — returns every project visible to the active user. Cheap, called once per corpus refresh. Adapters with no project concept (e.g. plain markdown vault) can return a single synthetic project.

listTasksInProject(projectId) — tasks visible in the project. Most task-app adapters return active work; history-oriented local adapters may also include completed items so decisions and prior implementation context remain searchable. Adapters with infinite or paginated lists should return at least the most recently modified N items (configurable) and document their inclusion policy.

getTask(projectId, taskId) — full task including content. Should be O(1) from the adapter's perspective.

createTask(input) — inserts. Returns the canonical Task with its assigned id and modifiedTime. If the adapter requires a project and input.projectId is missing, the adapter chooses (typically inbox).

updateTask(projectId, taskId, patch) — patch-style partial update. Adapter is responsible for preserving omitted fields. Returns the updated Task.

urlFor({ projectId, taskId }) — returns a deep-link URL the user can click to open the task in the adapter's native UI. Used by ats url to generate cross-reference markdown. Format is adapter-specific:

AdapterURL pattern (illustrative)
ticktickhttps://ticktick.com/webapp/#p/<projectId>/tasks/<taskId>
notionhttps://www.notion.so/<page-id>
obsidianobsidian://open?vault=<vault>&file=<path>
thingsthings:///show?id=<taskId>

If the adapter has no concept of a deep link (rare), return a stable identifier the adapter can resolve back via the CLI (e.g. ats-ref://<adapter>/<id>).

Adapters may also return a links array on tasks for native relationships such as dependencies. Core merges these links into graph, context, and event reads, but ATS metadata writes remain confined to the managed task-body block. This keeps the storage system authoritative for its own relationships.

Optional methods

The Core works without these, but uses them when present for speed or quality.

interface KnowledgeAdapter {
  searchByQuery?(query: string): Promise<Task[]>     // adapter's native search
  bulkFetch?(): Promise<Task[]>                       // single-call corpus refresh
  bulkFetchDelta?(opts: { cursor: any, since: number | null }):
    Promise<{ tasks: Task[], removedIds?: string[], cursor?: any } | null>
                                                      // incremental corpus refresh
  listCompletedTasks?(opts: { since?: string, until?: string, projectIds?: string[] }):
    Promise<Task[]>                                   // completed-task history
  embeddings?(texts: string[]): Promise<number[][]>  // adapter-supplied embeddings
}

searchByQuery(query) — if the adapter has a fast native search (e.g. Notion's full-text search, Obsidian's filesystem grep), Core uses it as one branch of ats find. Without it, Core falls back to substring scan on the cached corpus.

bulkFetch() — single-call corpus refresh. Cheaper than per-project iteration when the adapter supports it (Notion's database queries, TickTick's v2 batch sync, filesystem walk). Without it, Core iterates listProjectslistTasksInProject.

bulkFetchDelta({ cursor, since }) — incremental corpus refresh, used by ats cache sync when a cached corpus already exists. Implement it when the backend can answer "what changed since X" (an updated-since query, a sync token, file mtimes). Return changed tasks as whole items plus removedIds; Core applies them as replace-by-id over the prior corpus — never a field merge — and persists your returned cursor for the next call. Return null to request a full refresh. Backends without a changes API (TickTick's open API, for one) simply skip this hook and get the full-refresh path.

listCompletedTasks(opts) — completed-task history for retrospective queries. ats find --include-completed appends these per query (they are never written into the corpus cache), each carrying status: 'completed'. Adapters whose backend cannot list closed items simply omit the method; find then reports "completed history is not supported by this adapter" instead of silently answering from active tasks only.

embeddings(texts) — adapter-supplied vectors. Core uses them for a dense branch, fuses that branch with its token-based sparse branch, and supports generic similar. Without this method, Core stays on keyword/native retrieval; it does not silently start an embedding service.

Authentication

Adapters expose three lifecycle methods that the CLI delegates to:

interface KnowledgeAdapter {
  authStatus(): Promise<{ authenticated: boolean, expiresAt?: string }>
  authLogin(): Promise<{ url?: string, instructions: string }>
  authExchange?(code: string): Promise<void>
}

ats auth login calls authLogin() and prints whatever the adapter returns (typically an OAuth URL + paste-the-code instructions).

Configuration

Each adapter ships its own <adapter>.config.json schema. The CLI loads:

  • ~/.config/ats/config.json — global: which adapter is active, retrieval defaults
  • ~/.config/ats/<adapter>.json — adapter-specific: tokens, URLs, project IDs

The reference TickTick adapter stores OAuth tokens here. The Obsidian adapter stores the vault path. Notion stores the integration token + database IDs.

Worked example — the simplest possible adapter

// adapter-readonly-json.js — reads tasks from a static JSON file. Useful for testing.
import fs from 'fs';

const DATA = JSON.parse(fs.readFileSync('./tasks.json', 'utf8'));

export default {
  async listProjects() {
    const projectIds = [...new Set(DATA.map(t => t.projectId))];
    return projectIds.map(id => ({ id, name: id }));
  },
  async listTasksInProject(projectId) {
    return DATA.filter(t => t.projectId === projectId);
  },
  async getTask(projectId, taskId) {
    return DATA.find(t => t.projectId === projectId && t.id === taskId);
  },
  async createTask() { throw new Error('read-only'); },
  async updateTask() { throw new Error('read-only'); },
  urlFor({ taskId }) { return `file://./tasks.json#${taskId}`; },
};

That's a working adapter. ATS's find / get / url already work against it.

Stability promise

The required-methods contract is stable across v0.x. Optional methods may be added (Core falls back), never required. Breaking changes happen only at major versions with a deprecation notice in CHANGELOG.md.