Developer reference

The Noded Provider SDK

A provider is a small web service that connects a tool your customers use - a CRM, a meeting recorder, a data warehouse - to Noded. The Provider SDK (@bigfootai/sdk) is how you build one. You write a handful of TypeScript functions, and the SDK turns them into the HTTP endpoints the Noded platform calls to connect accounts and pull data into the graph. This page explains how the whole system works and documents every handler, each with example code, the request the platform sends, and the response it expects.

This is the other half of the developer surface. The Graph API reference covers reading and writing the graph from your own app. This page covers feeding the graph from an outside tool.

How it works

A provider is an Express server that you host. The Noded platform calls it over HTTPS. You never call the platform yourself, with one small exception during sign-in. Five facts explain almost everything on this page:

  • The platform calls you. Noded schedules syncs, retries failures, and pages through results. Your job is only to answer each request.
  • You store nothing. Your provider keeps no database and no tokens. Every request body carries the user's credential. When a user first connects, you hand their credential to the platform once, and the platform sends it back to you on every later call.
  • Data is pulled on a schedule. Most sync types run every few minutes. There is no webhook path in the SDK - polling plus fast cursor-driven pagination covers it.
  • You translate, Noded reconciles. Your handlers turn vendor JSON into Noded's primitive types (transcriptions, emails, tables, tags). The platform matches each item by its externalId and decides what to create, update, or archive.
  • One shared secret secures the channel. The platform authenticates to you with Authorization: Bearer using your BIGFOOT_API_KEY, and you authenticate to the platform with the same key.

You build all of this by passing plain functions to startServer(). Every group is optional - implement only what your tool supports. A meeting recorder might implement auth and sync.transcriptions and nothing else. That is a complete, useful provider.

At a glance

Packages@bigfootai/sdk plus @bigfootai/bigfoot-types (the data shapes)
ShapeAn Express 5 server. startServer() builds and starts it; you add handlers.
HostingYours - any host that can serve HTTPS. A Dockerfile ships with the template.
Auth inAuthorization: Bearer <BIGFOOT_API_KEY> on every platform call
HealthGET /api/v1/hello, provided for free
The whole ideaindex.ts
import { startServer } from '@bigfootai/sdk';

await startServer({
  auth: {
    // how a user connects their account
    connect, callback, check, refresh,
  },
  sync: {
    // how data gets pulled in
    transcriptions,
  },
});
Handler groupsall optional
interface StartServerOptions {
  auth?:     Partial<AuthMethods>;
  sync?:     Partial<SyncMethods>;
  link?:     Partial<LinkMethods>;
  tag?:      Partial<TagMethods>;
  upsert?:   Partial<UpsertMethods>;
  function?: Partial<FunctionMethods>;
  query?:    Partial<QueryMethods>;
}

Your first provider

Start from the template. It is a Copier template that asks for your provider's name and generates a runnable project: a stub src/index.ts, TypeScript and Biome config, a Dockerfile, and a CI workflow.

Three environment variables wire your provider to the platform. Put them in .env for local work:

Environment variables

BASE_URLRequired

The public URL of your provider. Used to build OAuth redirect URLs, like ${BASE_URL}/auth/callback.

BIGFOOT_API_KEYRequired

The shared secret between your provider and the platform. The SDK refuses to start without it, so a provider cannot accidentally run open to the internet. For local tests without a key, set ALLOW_UNAUTHENTICATED_PROVIDER_REQUESTS=true - never in production.

BIGFOOT_ENVIRONMENT_URLRequired

The platform URL your credentials are sent to after a user connects. You get this together with your API key.

PORT

The port to listen on. Defaults to 5000.

The SDK gives you a lot for free, so your code stays focused on the vendor API:

  • Route registration and request validation for every handler you provide.
  • Platform authentication middleware on every route except /auth/connect, /auth/callback, and the health check.
  • Structured logging (pino) with credential redaction built in. Tokens, keys, and secrets never reach your logs, even if you log a whole request body.
  • JSON body parsing (up to 50 MB), a health check at GET /api/v1/hello, static file serving from ./public (put your logo there), and a central error handler.

startServer() returns the Express app, so you can attach extra routes after it - a custom form endpoint, for example. The complete example below shows a full working provider.

Scaffoldterminal
copier copy gh:Notify-AI/bigfoot-sdk-template bigfoot-skylark
cd bigfoot-skylark
pnpm install
cp .env.example .env   # fill in the three variables
pnpm dev
Check it runsterminal
curl http://localhost:5000/api/v1/hello
# -> You look nice today :)
.env
BASE_URL=https://connect-skylark.example.com
BIGFOOT_API_KEY=nk_...        # from Noded
BIGFOOT_ENVIRONMENT_URL=https://api.getnoded.ai

Errors

Every error your provider returns is an application/problem+json response (RFC 7807). You do not need catch-all try/catch blocks: anything you throw is caught by the SDK's error handler, logged, and returned as a structured 500. But two errors deserve to be thrown on purpose, because the platform reacts to them:

SDK error classes

CredentialsExpiredError401

Throw this from any handler when the vendor API says the credentials are dead - a 401, a revoked token, a deleted key. It tells the platform to stop syncing and ask the user to reconnect. This is the single most important error to get right.

ValidationError400

Throw this for bad or missing input. The SDK also throws it for you when a request body is malformed, so your handlers can trust that credential and the filter object are present.

For anything more specific, build a problem yourself with ProblemDetailsBuilder from problem-details-http - for example, mapping a vendor 404 or 429 to the matching status. And for problems that should not fail the whole sync, do not throw at all: return your items and put a note in metadata.warnings instead.

When a sync keeps failing, the platform backs off for you. Each sync's health moves from healthy to degraded to failing, and finally to paused, with growing delays in between. A successful run - or a fresh reconnect after a CredentialsExpiredError - moves it back. You do not build retry logic; you just report honestly.

Handlerthe two throws that matter
import {
  CredentialsExpiredError,
  ValidationError,
} from '@bigfootai/sdk';

if (response.status === 401) {
  throw new CredentialsExpiredError(
    'Skylark rejected the access token',
  );
}

if (!filter.dateStart) {
  throw new ValidationError(
    'filter.dateStart must not be null',
  );
}
Responsewhat the platform sees
{
  "type": "https://getnoded.ai/credentials-expired",
  "title": "Credentials expired",
  "status": 401,
  "detail": "Skylark rejected the access token"
}
Warningpartial success, not failure
return {
  items: transcriptions,
  metadata: {
    warnings: [
      'Dropped 2 of 40 meetings: no transcript yet.',
    ],
  },
  pagination: { hasMore: false, pageSize: 38 },
};

Authentication: the connect flow

When a user clicks your integration in Noded, a small dance runs between the platform, your provider, and the vendor. It has four steps, and each step is one handler:

  1. The platform opens GET /auth/connect in a popup. Your auth.connect answers with one of two things: a redirect to the vendor's OAuth page, or an HTML form where the user pastes an API key. Both shapes are the union type AuthConnectResult.
  2. The user signs in with the vendor, which sends them back to GET /auth/callback. Your auth.callback exchanges the code (or validates the key) and returns a ConnectedProviderRegistration - the connection ID plus a Credential.
  3. If you implement auth.check, the SDK runs it right away. If the user granted too few scopes, the popup shows a friendly error page instead of completing.
  4. The SDK posts the registration to the platform (this is the one time your provider calls Noded), and the popup closes. From now on, every platform request carries that credential back to you.

One convention carries the context through the round trip: pack the connection ID and application into the OAuth state parameter as `${connectionId}.${application}`. Your callback splits it back apart, and the SDK uses it to run auth.check with the right application.

Later, on a schedule, the platform calls auth.refresh to keep tokens fresh, and may call auth.check again to spot revoked scopes early.

Your provider never stores the credential. The registration you return in step 2 is the only copy, and it lives with the platform. Every sync, tag, upsert, and function request arrives with connectedProvider.credential in its body. This is why a provider needs no database.

ShapeAuthConnectResult - both auth styles
type AuthConnectResult =
  | { type: 'redirect'; url: string }   // OAuth
  | { type: 'html'; html: string };     // key form
ShapeCredential - what you build once
interface Credential {
  accessToken: string;
  refreshToken?: string;
  dateExpiry?: number;    // epoch ms
  email: string;          // the user at the vendor
  instanceId?: string;
  instanceUrl?: string;   // per-tenant vendor host
  profile: unknown;       // anything useful to keep
}
Referenceroutes in this flow
GET  /auth/connect    # public
GET  /auth/callback   # public
POST /auth/check      # platform-authenticated
POST /auth/refresh    # platform-authenticated

auth.connect

authGET /auth/connect

Starts the connection. The platform opens this route in a popup when a user clicks your integration. You decide what the user sees next: send them to the vendor's OAuth page, or show them a form.

For OAuth, build the authorization URL with your redirect set to ${BASE_URL}/auth/callback and the state set to `${connectionId}.${application}`. For API-key tools, return HTML with a form that ends up at /auth/callback carrying the key and the same state.

Arguments

applicationstring

Which of your applications the user is connecting. A provider with one application still receives it - guard against names you do not serve.

connectionIdstring

The platform's ID for this connection attempt. Thread it through state so your callback can return it.

Returns

AuthConnectResult - either { type: 'redirect', url } or { type: 'html', html }. The SDK issues the redirect or serves the HTML for you.

A key too big for a URL? Some credentials - a private key, a long PEM - do not fit in a query string. The pattern: your form POSTs the secret to a custom route you add on the app, that route stores it briefly and returns a short token, and only the token travels through /auth/callback. The Snowflake provider works this way.

HandlerOAuth redirect
async connect(logger, application, connectionId) {
  if (application !== 'calls') {
    throw new Error('Unsupported application');
  }
  return {
    type: 'redirect',
    url: client.buildAuthorizationUrl(oauthConfig, {
      redirect_uri: `${process.env.BASE_URL}/auth/callback`,
      state: `${connectionId}.${application}`,
      code_challenge: await pkceChallenge(),
      code_challenge_method: 'S256',
    }).toString(),
  };
}
HandlerAPI-key form
async connect(logger, application, connectionId) {
  return {
    type: 'html',
    html: renderKeyForm({
      // the form submits to /auth/callback with
      // ?apiKey=...&state=connectionId.application
      action: '/auth/callback',
      state: `${connectionId}.${application}`,
    }),
  };
}

auth.callback

authGET /auth/callback

Finishes the connection. The vendor (or your own form) sends the user here. You exchange the authorization code for tokens - or validate the pasted key - and return a ConnectedProviderRegistration.

After you return, the SDK does the rest: it runs auth.check if you have one, posts the registration to the platform, and closes the popup. If anything you throw reaches the SDK, the user sees a connection error page instead.

Arguments

uristring

The full callback URL, query string included. OAuth libraries take this directly for the code exchange.

queryParamsURLSearchParams

The parsed query parameters. Read state here and split it into connectionId and application.

Returns

A ConnectedProviderRegistration: { connectionId, credential }. Fill the credential as completely as you can - email identifies the user at the vendor, dateExpiry lets the platform refresh before tokens die, and instanceUrl is required later if you implement tag or upsert.

For API keys that never expire, set dateExpiry far in the future - a year is the convention - and have refresh simply re-validate the key.

Handler
async callback(logger, uri, queryParams) {
  const state = queryParams.get('state');
  if (!state) {
    throw new Error('Missing OAuth callback state');
  }
  const [connectionId] = state.split('.');

  const tokens = await client.authorizationCodeGrant(
    oauthConfig, new URL(uri),
    { expectedState: state, pkceCodeVerifier: verifier },
  );
  const me = await skylark.getMe(tokens.access_token);

  return {
    connectionId,
    credential: {
      accessToken: tokens.access_token,
      refreshToken: tokens.refresh_token,
      dateExpiry: Date.now() + tokens.expires_in * 1000,
      email: me.email,
      profile: me.display_name,
    },
  };
}
What you returnsent to the platform for you
{
  "connectionId": "7b1e5c2a-9f4d-4e8b-a1c3-2d5e8f0a1b4c",
  "credential": {
    "accessToken": "sky_at_9f8e7d...",
    "refreshToken": "sky_rt_1a2b3c...",
    "dateExpiry": 1757090980000,
    "email": "mia@acmerobotics.com",
    "profile": "Mia Chen"
  }
}

auth.check

authPOST /auth/check

Answers one question: do these credentials still cover everything this application needs? The SDK calls it right after auth.callback, and the platform calls it again periodically to catch scopes a user revoked later.

The useful pattern is to actually probe the vendor API - hit the endpoints your syncs depend on and see if they answer. Returning scope_required with a plain-language message is what turns a silent sync failure into a prompt the user can act on.

Arguments

applicationstring

The application to check for. Different applications may need different scopes.

credentialCredential

The credentials to validate.

Returns

An AuthCheckResult: { status: 'ok' }, or { status: 'scope_required', message }. During connect, scope_required stops the flow and shows the user an error page. Write the message for the user, not for a log file.

Handler
async check(logger, application, credential) {
  try {
    await skylark.getMe(credential.accessToken);
    await skylark.listRecordings(credential.accessToken,
      { limit: 1 });
    return { status: 'ok' };
  } catch {
    return {
      status: 'scope_required',
      message:
        'Noded needs permission to read your ' +
        'Skylark recordings. Please reconnect and ' +
        'approve all requested permissions.',
    };
  }
}
ResponsePOST /auth/check
{
  "result": {
    "status": "scope_required",
    "message": "Noded needs permission to read your Skylark recordings. Please reconnect and approve all requested permissions."
  }
}

auth.refresh

authPOST /auth/refresh

Keeps the connection alive. The platform watches each credential's dateExpiry and calls this handler before it passes. You use the refresh token to get new tokens and return the updated credential; the platform stores it and uses it from then on.

Arguments

connectedProviderConnectedProvider

The full connection, with credential, provider, application, and connectionId. Read the refresh token from connectedProvider.credential.refreshToken.

Returns

An AuthRefreshProviderResponse: { credential } with the new tokens and a new dateExpiry. Spread the old credential first so fields like email and instanceUrl survive the refresh.

If the vendor rejects the refresh token itself, throw CredentialsExpiredError. The platform counts refresh failures, and that error is what routes the user to a clean reconnect instead of an endless retry loop. For API keys, re-validate the key and return the credential unchanged.

Handler
async refresh(logger, connectedProvider) {
  const existing = connectedProvider.credential;
  if (!existing?.refreshToken) {
    throw new CredentialsExpiredError(
      'No refresh token on file');
  }

  const tokens = await client.refreshTokenGrant(
    oauthConfig, existing.refreshToken);

  return {
    credential: {
      ...existing,
      accessToken: tokens.access_token,
      refreshToken: tokens.refresh_token,
      dateExpiry: Date.now() + tokens.expires_in * 1000,
    },
  };
}

Concept: providers and applications

Your service is one provider - skylark, say. A provider offers one or more applications: the tiles a user actually connects in Noded. Google is one provider with several applications (calendar, email, drive); a meeting tool is usually one provider with one application.

The provider definition is what registers your service with Noded. It says where your provider lives (instanceUrl - the base URL the platform calls) and, for each application, which capabilities it supports. The sobjects flags are the important part: each flag you turn on is a promise that the matching handler exists. Turn on transcription: true and the platform will start posting to your /sync/transcription.

The application name is also the thread that runs through everything: it arrives in auth.connect, travels inside the OAuth state, names the /:application/link route, and comes back in every credential's connectedProvider.application. Guard it in your handlers - reject names you do not serve.

Submitting your definition. Today the Noded team registers provider definitions with you during onboarding - request developer access to get started. Self-serve submission is on the way, and the shape shown here is exactly what you will submit.

Shapea provider definition
{
  "name": "skylark",
  "label": "Skylark",
  "description": "Skylark meetings for Noded",
  "instanceUrl": "https://connect-skylark.example.com",
  "iconUrl": "https://connect-skylark.example.com/images/logo.svg",
  "applications": [
    {
      "name": "calls",
      "label": "Skylark Calls",
      "description": "Ingest Skylark meeting recordings as transcripts in Noded",
      "authentication": true,
      "sobjects": {
        "calendar": false,
        "transcription": true,
        "message": false,
        "email": false,
        "table": false,
        "function": false
      },
      "linkSubscriptions": [],
      "externalIds": []
    }
  ]
}

Concept: primitives

Everything Noded stores is a typed object from @bigfootai/bigfoot-types. Your handlers produce these primitives, and each sync handler produces exactly one kind:

What each handler produces

HandlerPrimitiveWhat it is
sync.transcriptionsTranscriptionA meeting: attendees, transcript, highlights, tasks
sync.emailsEmailA message with to/cc/from as PersonReferences
sync.calendarCalendarA calendar holding its Events
sync.metadata / sync.tableTableA dataset: metadata.fields (columns) plus records (rows)
sync.identifierExternalObjectWithUrlA vendor identity to match with a Noded tag
sync.insightTagInsightResponsePer-tag data, usually tables
tag.tagTagA person or topic in the graph
link.linkBusinessObjectA document behind a pasted URL

Most of these extend BusinessObject, which is where provenance lives. Four fields matter on every item you return:

  • provider and application - your names, so Noded knows where it came from.
  • uri - a link back to the item in the vendor's own UI.
  • externalId - the vendor's stable ID for the item. This is the item's identity in Noded; the next concept explains why.

People do not have their own sync. They arrive as PersonReference objects on the things they appear in - a transcription's attendees, an email's to and from - and Noded matches them into the graph by email.

Examplea Transcription, trimmed
{
  "blockType": "transcription",
  "provider": "skylark",
  "application": "calls",
  "externalId": "rec_8f2e91c4",
  "uri": "https://app.skylark.example.com/rec/8f2e91c4",
  "title": "Acme Robotics - renewal call",
  "dateStart": 1757005200000,
  "duration": 2700,
  "organizedBy": { "email": "mia@acmerobotics.com",
                   "friendlyName": "Mia Chen" },
  "attendees": [
    { "email": "mia@acmerobotics.com",
      "friendlyName": "Mia Chen" },
    { "email": "sam@yourcompany.com",
      "friendlyName": "Sam Ortiz" }
  ],
  "transcript": { "title": "Transcript",
                  "text": "Mia: Thanks for joining..." },
  "processingStage": "processed"
}

Concept: external IDs are identity

Your provider returns the same items again and again - that is what polling means. The platform does not blindly insert them. A reconciler compares each incoming item to what is already stored, using externalId (together with your provider and application) as the key:

  • New externalId - the item is created.
  • Known externalId, changed content - the stored item is updated in place.
  • Known externalId, same content - nothing happens.

So the one rule: an item's externalId must never change between syncs. Use the vendor's own stable ID. Never build it from anything that can shift - a title, a date, a page position. If your IDs are not stable, every sync creates duplicates of everything.

Tags work with the same idea from the other direction. The sync.identifier handler and externalIdLinks connect a vendor identity ("account 001Xj000ABC in the CRM") to a Noded tag ("Acme Robotics"), which is how records and insights land on the right account page.

Never put a dot in metadataType. Table external IDs join the pieces with . as the separator, so a metadata type like sales.orders corrupts every ID built from it. Use sales_orders.

Examplestable vs unstable
// CORRECT - the vendor's own ID, stable forever
externalId: meeting.uuid

// WRONG - changes when the meeting is renamed
externalId: `${meeting.topic}-${meeting.date}`
Examplewhat reconciliation reports
{
  "created": 3,
  "updated": 1,
  "unchanged": 34,
  "skipped": 0,
  "failedCount": 0
}

Concept: the sync lifecycle

Once a user connects, the platform takes over. For each connection, it registers one sync per capability your provider definition declares. Then, on repeat:

  1. Schedule. Most sync types run every few minutes; insights run daily; link is not scheduled at all - it fires when a user pastes a matching URL.
  2. Call. The platform posts to your endpoint with the connection's credential and a filter. For paginated types, the filter carries a date window and, after the first page, your cursor.
  3. Reconcile. Your items are matched by externalId and created, updated, or left alone.
  4. Continue or wait. If you returned hasMore: true with a new cursor, the platform calls again immediately. Otherwise it waits for the next scheduled run.

Failures feed a health state for each sync - healthy, degraded, failing, paused - with growing backoff between attempts. A CredentialsExpiredError short-circuits this: syncing stops and the user is asked to reconnect, and a successful reconnect resumes it.

The practical effect: your handlers should be fast, honest, and stateless. Answer with one page of data, report the truth in pagination and warnings, and let the platform drive.

Referencedefault cadence
calendar        every ~5 minutes
email           every ~5 minutes  (paginated)
transcription   every ~5 minutes  (paginated)
table           every ~5 minutes
identifier      every ~5 minutes
insight         every ~24 hours
link            on demand (pasted URL)
Requestevery sync body looks like this
{
  "connectedProvider": {
    "connectionId": "7b1e5c2a-9f4d-4e8b-a1c3-2d5e8f0a1b4c",
    "provider": "skylark",
    "application": "calls",
    "credential": {
      "accessToken": "sky_at_9f8e7d...",
      "email": "mia@acmerobotics.com"
    }
  },
  "transcriptionFilter": {
    "dateStart": 1756400400000,
    "dateEnd": 1757005200000,
    "limit": 30
  }
}

Pagination

Email and transcription syncs are paginated, and pagination is the part of the contract most worth learning properly. The platform drives the loop; you supply two signals per page:

  • pagination.hasMore - is there another page?
  • pagination.cursor - an opaque string that means "resume here". On the first call, filter.cursor is undefined. On every later call, it is exactly the string you returned last time. The platform never looks inside it - the format is entirely yours.

When you return hasMore: true and a cursor different from the one you received, the platform calls you again immediately - a large backfill completes in minutes, not days. When you return hasMore: false, or the cursor did not change, the loop ends and normal scheduling resumes.

The loop guard is silent. Returning hasMore: true with the same cursor - or with no cursor - does not error. Pagination just stops. If your backfills mysteriously end after one page, this is almost always why.

Three cursor strategies cover every vendor API:

1. Pass-through. The vendor API has its own page token. Return it as your cursor; feed it back to the vendor next call. Most APIs work this way - the example on the right is real.

2. Structured. The vendor has no cursor, so you build one: a JSON string recording your position, such as a time window boundary. Advance it each page.

3. Offset. The vendor pages by number. The cursor is the offset as a string: "150".

Two more rules keep pages consistent:

Use the date window you are given. The platform computes dateStart / dateEnd once, on page one, and repeats the same window on every later page. Never call Date.now() to rebuild it - a shifting window skips or duplicates data at the seams.

Empty pages are fine. A time-window cursor may cross a quiet week: zero items, but the cursor advances. Return items: [] with hasMore: true and the new cursor. That is a normal page, not an error.

Common mistakes

MistakeWhat happens
hasMore: true on the last pageOne wasted extra call, possible duplicates
hasMore: true, cursor missingPagination stops silently after this page
Date.now() on page 2+Window shifts; data is missed or duplicated
pageSize = requested limitMetrics lie; report the actual count returned
Throwing on an empty vendor responseA healthy sync is marked failing; return [] instead
Shapeevery sync returns this envelope
interface StandardSyncResponse<T> {
  items: T[];               // this page, may be empty
  metadata: {
    warnings?: string[];    // non-fatal notes
  };
  pagination: {
    hasMore: boolean;
    cursor?: string;        // omit on the last page
    pageSize: number;       // items actually returned
  };
}
Handlerpass-through cursor, end to end
async function syncTranscriptions(logger, credential,
    filter) {
  // First page: filter.cursor is undefined.
  // Later pages: it is the token we returned below.
  const page = await skylark.listRecordings(
    credential.accessToken, {
      from: filter.dateStart,   // given - never Date.now()
      to: filter.dateEnd,
      pageSize: filter.limit,
      pageToken: filter.cursor,
    });

  const items = page.recordings.map(toTranscription);

  return {
    items,
    metadata: {},
    pagination: {
      cursor: page.nextPageToken || undefined,
      hasMore: !!page.nextPageToken,
      pageSize: items.length,
    },
  };
}
Examplea valid empty page
{
  "items": [],
  "metadata": {},
  "pagination": {
    "hasMore": true,
    "cursor": "{\"resumeFrom\":1756832400000}",
    "pageSize": 0
  }
}

Conventions

A few rules hold across every handler. Learn them once and the reference below will feel repetitive - which is the point.

Timestamps are epoch milliseconds. Every date - dateStart, dateEnd, dateExpiry - is a number like 1757005200000. In JavaScript, Date.now() and new Date(value) speak this format natively. Convert at the vendor boundary and nowhere else.

The credential arrives in every body. Handlers receive it as their second argument, already validated by the SDK. The usual first line of a handler is const client = await authenticate(credential) - a helper of yours that builds a vendor client and throws CredentialsExpiredError if the token is dead.

instanceUrl is required for tag and upsert. These handlers reach into a specific vendor org, so the SDK rejects their requests up front when the credential has no instanceUrl. Capture it in auth.callback for vendors with per-tenant hosts.

Guard the application. Every connect and sync tells you which application it is for. Throw on names you do not serve - it turns a misconfiguration into a clear error instead of confusing data.

pageSize is the count you returned. Not the limit you were asked for. It feeds sync metrics.

Keep vendor code in one file per primitive. The convention across every real provider: src/index.ts holds only the startServer wiring, src/system/authentication.ts owns the vendor's auth, and each primitive gets its own file under src/primitives/. Handlers stay three lines: guard, authenticate, delegate.

Referencethe provider layout
bigfoot-skylark/
  src/
    index.ts                  # startServer wiring only
    system/
      authentication.ts       # connect/callback/refresh
    primitives/
      transcription.ts        # one file per primitive
  public/
    images/logo.svg           # served for free
  .env
  Dockerfile
Handlerthe three-line shape
async transcriptions(logger, credential, filter) {
  const client = await authenticate(credential);
  return await syncTranscriptions(logger, client, filter);
}

Sync handlers

Each handler below answers one platform endpoint. All of them receive (logger, credential, request) and return a StandardSyncResponse of their primitive. Implement only the ones your provider definition declares - the platform never calls the others.

sync.transcriptions

syncPOST /sync/transcription

Returns meetings as Transcription objects: who attended, what was said, and optionally highlights, an outline, and tasks that came out of the meeting. This is the handler for meeting recorders and note-takers, and the most commonly built one.

It is paginated - follow the pagination contract. Fetch meetings inside the given date window, convert each to a Transcription, and pass the vendor's page token through as your cursor.

Arguments (TranscriptionFilter)

dateStart, dateEndnumberRequired

The window to fetch, in epoch milliseconds. Stable across pages - use it as given.

limitnumber

How many items the platform would like per page.

cursorstring

Absent on the first page; afterwards, exactly the string you returned last time.

Returns

StandardSyncResponse<Transcription>. Set dateStart, duration, attendees (as PersonReferences with emails - that is how people land on the right pages), and transcript. Meetings with no transcript yet are worth skipping with a warning rather than returning half-empty.

Handler
async transcriptions(logger, credential, filter) {
  const page = await skylark.listRecordings(
    credential.accessToken, {
      from: filter.dateStart,
      to: filter.dateEnd,
      pageSize: filter.limit,
      pageToken: filter.cursor,
    });

  return {
    items: page.recordings.map(toTranscription),
    metadata: {},
    pagination: {
      cursor: page.nextPageToken || undefined,
      hasMore: !!page.nextPageToken,
      pageSize: page.recordings.length,
    },
  };
}
Responseone item, trimmed
{
  "items": [
    {
      "blockType": "transcription",
      "provider": "skylark",
      "application": "calls",
      "externalId": "rec_8f2e91c4",
      "uri": "https://app.skylark.example.com/rec/8f2e91c4",
      "title": "Acme Robotics - renewal call",
      "dateStart": 1757005200000,
      "duration": 2700,
      "attendees": [
        { "email": "mia@acmerobotics.com",
          "friendlyName": "Mia Chen" }
      ],
      "transcript": {
        "title": "Transcript",
        "text": "Mia: Thanks for joining..."
      },
      "processingStage": "processed"
    }
  ],
  "metadata": {},
  "pagination": {
    "cursor": "sky_page_2",
    "hasMore": true,
    "pageSize": 1
  }
}

sync.emails

syncPOST /sync/email

Returns messages as Email objects. Same paginated contract as transcriptions; email is usually the highest-volume sync, so the cursor discipline matters most here. Providers that sweep a large mailbox often use a structured JSON cursor that records exactly where the sweep stands.

Arguments (EmailFilter)

dateStart, dateEndnumber

The window to fetch, in epoch milliseconds. Stable across pages.

limitnumber

Requested page size.

cursorstring

Your cursor from the previous page, or absent on page one.

includeEmails[string]

When present, only fetch mail involving these addresses. It narrows a mailbox sweep to the people that matter to the graph.

Returns

StandardSyncResponse<Email>. Fill from, to, cc with PersonReferences, plus subject, body fields, and a stable externalId (the vendor's message ID).

Handlerstructured cursor
async emails(logger, credential, filter) {
  const pos = filter.cursor
    ? JSON.parse(filter.cursor)
    : { resumeFrom: filter.dateStart };

  const windowEnd = Math.min(
    pos.resumeFrom + WINDOW_MS, filter.dateEnd);
  const page = await skylark.listMail(
    credential.accessToken,
    { after: pos.resumeFrom, before: windowEnd });

  const hasMore = windowEnd < filter.dateEnd;
  return {
    items: page.messages.map(toEmail),
    metadata: {},
    pagination: {
      hasMore,
      cursor: hasMore
        ? JSON.stringify({ resumeFrom: windowEnd })
        : undefined,
      pageSize: page.messages.length,
    },
  };
}

sync.calendar

syncPOST /sync/calendar

Returns the user's calendars, each carrying its events for the requested window. Not paginated - return the full window in one call with hasMore: false.

Arguments (SyncCalendarProviderRequest)

calendarFilter.dateStart, calendarFilter.dateEndnumberRequired

The event window, in epoch milliseconds.

calendarFilter.limitnumber

A cap on events per calendar.

connectedProviderConnectedProvider

This handler receives the whole request object, so the connection is on it too.

Returns

StandardSyncResponse<Calendar> - one item per calendar, each with timeZone and an events array. Every event needs its own stable externalId and its attendees as PersonReferences.

Handler
async calendar(logger, credential, request) {
  const { dateStart, dateEnd } = request.calendarFilter;
  const calendars = await skylark.listCalendars(
    credential.accessToken, { dateStart, dateEnd });

  const items = calendars.map(toCalendar);
  return {
    items,
    metadata: {},
    pagination: { hasMore: false,
                  pageSize: items.length },
  };
}

sync.metadata

syncPOST /sync/metadata

Describes your tables before any rows flow. The platform sends the table definitions it knows about for your application, and you send back Table objects with the metadata enriched from the vendor's real schema - actual field lists, labels, and picklist options. It runs when a user connects, so what you return here decides which datasets Noded offers to set up.

The request's tableMetadata is your starting point: find the definitions you recognize by metadataType, enrich their fields from the vendor's schema API, and wrap each in a Table with a stable externalId and uri. Not paginated.

Arguments (SyncTableMetadataProviderRequest)

tableMetadata[TableMetadata]Required

The definitions the platform holds for your application. Each has a metadataType (like "account"), a label, and fields.

connectedProviderConnectedProvider

The connection, on the request object.

Returns

StandardSyncResponse<Table> - tables with enriched metadata and no records. Rows come later through sync.table.

Keep metadataType dot-free. See external IDs - a dot corrupts every table ID derived from it.

Handler
async metadata(logger, credential, request) {
  const client = await authenticate(credential);
  const tables = [];

  for (const meta of request.tableMetadata) {
    const schema = await client.describeObject(
      meta.metadataType);
    if (!schema) { continue; }

    meta.fields = schema.fields.map(toField);
    tables.push(buildTable(credential, meta));
  }

  return {
    items: tables,
    metadata: {},
    pagination: { hasMore: false,
                  pageSize: tables.length },
  };
}
Responseone table, trimmed
{
  "items": [
    {
      "blockType": "table",
      "provider": "skylark",
      "application": "crm",
      "externalId": "skylark.crm.account",
      "uri": "https://app.skylark.example.com/objects/account",
      "metadata": {
        "metadataType": "account",
        "label": "Accounts",
        "fields": [
          { "name": "Name", "label": "Account name",
            "fieldType": "text" },
          { "name": "RenewalDate", "label": "Renewal date",
            "fieldType": "date" }
        ]
      }
    }
  ],
  "metadata": {},
  "pagination": { "hasMore": false, "pageSize": 1 }
}

sync.table

syncPOST /sync/table

Returns the rows. For each table the user has set up, the platform asks for records - and it tells you exactly which ones it wants: the request carries the table's metadata and, usually, the externalIds of the rows to fetch (the vendor records already linked to Noded tags).

Arguments (SyncTableProviderRequest)

recordFilter.recordTypeFilters[RecordTypeFilter]Required

One entry per table: { tableMetadata, externalIds }. Fetch the rows whose vendor IDs are listed. An empty externalIds list is your cue to seed - return a sensible first page of rows so the table is not empty on day one.

connectedProviderConnectedProvider

The connection, on the request object.

Returns

StandardSyncResponse<Table> - each table now carrying records. A record is a list of values, each pairing a field name from the metadata with a string value, plus its own stable externalId. Not paginated - hasMore: false.

Handler
async table(logger, credential, request) {
  const client = await authenticate(credential);
  const items = [];

  for (const f of request.recordFilter.recordTypeFilters) {
    const rows = f.externalIds.length > 0
      ? await client.getRecords(
          f.tableMetadata.metadataType, f.externalIds)
      : await client.recentRecords(       // seed case
          f.tableMetadata.metadataType, 50);

    items.push(buildTableWithRecords(
      credential, f.tableMetadata, rows));
  }

  return {
    items,
    metadata: {},
    pagination: { hasMore: false,
                  pageSize: items.length },
  };
}
Responsea record, trimmed
{
  "externalId": "001Xj000ABC",
  "values": [
    { "name": "Name", "value": "Acme Robotics" },
    { "name": "RenewalDate", "value": "2027-01-15" }
  ]
}

sync.identifier

syncPOST /sync/identifier

Matches vendor identities to Noded tags. The platform sends the "slots" it wants filled - each names a metadataType and a tag type, often with a tag's name or email to match on - and you answer with the vendor objects that fit, each carrying its externalId and a URL. These become the externalIdLinks that put CRM records, boards, and channels on the right account and person pages.

Arguments (SyncExternalIdLinksProviderRequest)

externalIdLinkObjects[SyncExternalIdLinkObject]

The slots to resolve. Each has metadataType, tagType (and maybe tagSubType), plus matching hints: name, email, sourceTagId.

connectedProviderConnectedProvider

The connection, on the request object.

Returns

StandardSyncResponse<ExternalObjectWithUrl> - the same objects filled in: externalId set to the vendor's ID, url pointing at the vendor's UI, and the matching hints echoed back so the platform knows which slot each answer belongs to.

Responseone resolved identity
{
  "items": [
    {
      "metadataType": "account",
      "tagType": "topic",
      "tagSubType": "organization",
      "name": "Acme Robotics",
      "externalId": "001Xj000ABC",
      "url": "https://app.skylark.example.com/accounts/001Xj000ABC"
    }
  ],
  "metadata": {},
  "pagination": { "hasMore": false, "pageSize": 1 }
}

sync.insight

syncPOST /sync/insight

Returns per-tag data on a slow cadence - by default, daily. Where sync.table answers "give me these rows", insight answers "for each of these accounts, what does your tool know?" - usage numbers, health metrics, anything that reads well as a small table on an account page.

Arguments (InsightFilter)

insightTypeFilters[InsightTypeFilter]Required

One entry per insight type: the tagType/tagSubType it applies to, the blockType to produce (usually table), optional tableMetadata, and the batch of tags to answer for - each with its identifying fields, including any external ID link for your provider.

Returns

StandardSyncResponse<TagInsightResponse> - each item is { blockType, tables }, a table of insight rows for the batch. Rows land on their tags through the external IDs they carry.

Handlershape only
async insight(logger, credential, filter) {
  const client = await authenticate(credential);
  const items = [];

  for (const f of filter.insightTypeFilters) {
    const table = await buildUsageTable(
      client, f.tableMetadata, f.tags);
    items.push({ blockType: 'table',
                 tables: [table] });
  }

  return {
    items,
    metadata: {},
    pagination: { hasMore: false,
                  pageSize: items.length },
  };
}

Writing back

Sync pulls data in; upsert pushes Noded content out to your vendor. Both handlers require instanceUrl on the credential, because a write has to land in a specific vendor org.

upsert.table

writePOST /upsert/table

Writes records into your vendor. The platform sends a Table whose records should be created or updated - a record with an externalId is an update to that vendor object; a record without one is a create.

Arguments (UpsertTableProviderRequest)

tableTableRequired

The table to write: metadata.metadataType names the vendor object, and each record's values carry the fields to set.

Returns

The Table back, with every record's externalId filled in - including the IDs the vendor just assigned to created records. That is how Noded links the rows to their new vendor objects.

Handler
async table(logger, credential, request) {
  const client = await authenticate(credential);
  const { table } = request;

  for (const record of table.records ?? []) {
    record.externalId = record.externalId
      ? await client.updateRecord(
          table.metadata.metadataType, record)
      : await client.createRecord(
          table.metadata.metadataType, record);
  }

  return table;
}

upsert.notes

writePOST /upsert/notes

Pushes Noded notes into your vendor - meeting notes onto the CRM record they belong to, for example. Each incoming ExternalNote arrives with its content already rendered as HTML (documentHtml) and with tags whose external IDs tell you which vendor objects to attach it to.

Arguments (UpsertNotesProviderRequest)

externalNotes[ExternalNote]Required

The notes to write. Each has title, documentHtml, a uri back to Noded, and tags with vendor externalIds for attachment.

Returns

ExternalNote[] - the notes back, each with externalId set to the vendor's ID for the created or updated note.

Request bodyone note, trimmed
{
  "externalNotes": [
    {
      "_id": "3c4d5e6f-7a8b-4c9d-8e0f-1a2b3c4d5e6f",
      "externalId": "",
      "title": "Renewal call notes",
      "documentHtml": "<h1>Renewal call</h1><p>Agreed to...</p>",
      "uri": "https://app.getnoded.ai/note/3c4d5e6f",
      "tags": [
        { "externalId": "001Xj000ABC",
          "tagType": "topic" }
      ]
    }
  ]
}

Functions & queries

The advanced surface: actions Noded's agents can take through your provider, and live searches against your vendor. Most providers ship without these - add them when your tool has something an agent should be able to do, not just read.

function.resolveInputs

actionPOST /install/function/inputs

Called once, during the install wizard, after the user has filled in a function's configuration - say, picked a slide template. You inspect what the configuration points at and report the inputs the function will need each time it runs (for a template, the placeholder tokens found inside it).

Arguments (ResolveFunctionInputsProviderRequest)

functionNamestringRequired

Which of your declared functions is being installed.

configuration[FieldValue]Required

The install-time values the user set, as name/value pairs.

Returns

{ inputs: FunctionInput[] } - each input with a name, a label, and a default valueSource (usually { kind: 'userInput' }), which the installer can remap in the UI.

Response
{
  "inputs": [
    {
      "name": "customer_name",
      "label": "Customer name",
      "valueSource": { "kind": "userInput" }
    },
    {
      "name": "renewal_date",
      "label": "Renewal date",
      "valueSource": { "kind": "userInput" }
    }
  ]
}

function.execute

actionPOST /execute/function

Runs the function: clone the template, send the email, launch the workflow. The call is synchronous - do the work, then return. If the function produced something with a URL, return it as outputUrl and the platform ingests it as an attachment on the invocation.

Note the two credentials on the request. templateConnectedProvider belongs to whoever installed the function - use it to read assets they own, like the template. outputConnectedProvider belongs to whoever is running it - use it to write the result somewhere they control. Providers that do not need the split just use outputConnectedProvider.

Arguments (ExecuteFunctionProviderRequest)

functionNamestringRequired

Which function to run.

configuration[FieldValue]Required

The install-time values.

values[FieldValue]Required

The per-run input values, matching what resolveInputs declared.

templateConnectedProvider, outputConnectedProviderConnectedProvider

Installer's and executor's connections, as described above.

Returns

{ outputUrl?, externalId?, uri? } - all optional. Return outputUrl when there is a result worth attaching.

Response
{
  "outputUrl": "https://app.skylark.example.com/decks/9a8b7c6d",
  "externalId": "deck_9a8b7c6d",
  "uri": "https://app.skylark.example.com/decks/9a8b7c6d"
}

query.execute

actionPOST /execute/query

Runs a live search against your vendor and returns matching records - powering installed queries that Noded's surfaces and agents can call. You receive the object type, a list of criteria, the fields to return, and a limit; you translate them into the vendor's query language.

Arguments (ExecuteQueryProviderRequest)

metadataTypestringRequired

The vendor object type to search.

searchCriteria[SearchCriterion]Required

Each is { fieldName, operator, value }.

returnFields[string]

The fields to include in each result's values.

limitnumber

Maximum records to return.

Returns

{ records, hasMore } - each record with title, externalId, uri, and the requested values. Set hasMore: true when the vendor had more than limit matches.

Responseone record, trimmed
{
  "records": [
    {
      "provider": "skylark",
      "application": "crm",
      "metadataType": "account",
      "externalId": "001Xj000ABC",
      "title": "Acme Robotics",
      "uri": "https://app.skylark.example.com/accounts/001Xj000ABC",
      "values": [
        { "name": "RenewalDate", "value": "2027-01-15" }
      ]
    }
  ],
  "hasMore": false
}

A complete example

Here is an entire working provider for a fictional meeting tool, Skylark. It supports OAuth sign-in and transcription sync - the same shape as the real Zoom provider, whose index.ts is 65 lines. Three files:

src/index.ts (right, top) is nothing but wiring. Every handler is a guard, an authenticate, and a delegate. This file should stay boring.

src/system/authentication.ts (right, middle) owns the vendor's OAuth. Notice what the callback stores: tokens, expiry, the user's email, and a display name in profile. That credential is the last state this service will ever hold - and it holds it only long enough to return it.

src/primitives/transcription.ts (right, bottom) does the real work: call the vendor inside the given window, map each recording to a Transcription with a stable externalId, and pass the vendor's page token through as the cursor.

That is the whole pattern. A tables provider swaps the primitive file for metadata/table handlers; an email provider swaps in emails. The wiring and auth files barely change.

src/index.tscomplete
import { startServer } from '@bigfootai/sdk';
import { syncTranscriptions }
  from './primitives/transcription.js';
import { authCallback, authenticate,
  getAuthenticationUrl, refreshToken }
  from './system/authentication.js';

await startServer({
  auth: {
    async connect(logger, application, connectionId) {
      if (application !== 'calls') {
        throw new Error('Unsupported application');
      }
      return {
        type: 'redirect',
        url: await getAuthenticationUrl(
          `${connectionId}.${application}`),
      };
    },
    async callback(logger, uri, queryParams) {
      const state = queryParams.get('state');
      if (!state) {
        throw new Error('Missing OAuth state');
      }
      return await authCallback(
        uri, state.split('.')[0], state);
    },
    async check(logger, application, credential) {
      await authenticate(credential);
      return { status: 'ok' };
    },
    async refresh(logger, connectedProvider) {
      return await refreshToken(connectedProvider);
    },
  },
  sync: {
    async transcriptions(logger, credential, filter) {
      const conn = await authenticate(credential);
      return await syncTranscriptions(
        logger, conn, filter);
    },
  },
});
src/system/authentication.tscondensed
export const getAuthenticationUrl = async (state) =>
  client.buildAuthorizationUrl(oauthConfig, {
    redirect_uri:
      `${process.env.BASE_URL}/auth/callback`,
    state,
    code_challenge: await pkceChallenge(),
    code_challenge_method: 'S256',
  }).toString();

export const authCallback = async
    (uri, connectionId, state) => {
  const tokens = await client.authorizationCodeGrant(
    oauthConfig, new URL(uri),
    { expectedState: state,
      pkceCodeVerifier: verifier });
  const me = await skylark.getMe(tokens.access_token);
  return {
    connectionId,
    credential: {
      accessToken: tokens.access_token,
      refreshToken: tokens.refresh_token,
      dateExpiry:
        Date.now() + tokens.expires_in * 1000,
      email: me.email,
      profile: me.display_name,
    },
  };
};

export const authenticate = async (credential) => {
  // Probe the vendor; throws on a dead token.
  await skylark.getMe(credential.accessToken);
  return credential;
};
src/primitives/transcription.tscondensed
export const syncTranscriptions = async
    (logger, credential, filter) => {
  const page = await skylark.listRecordings(
    credential.accessToken, {
      from: filter.dateStart,
      to: filter.dateEnd,
      pageSize: filter.limit,
      pageToken: filter.cursor,
    });

  const items = page.recordings.map((rec) => {
    const t = new Transcription(
      null, [], 'skylark', 'calls',
      rec.shareUrl, rec.uuid,        // uri, externalId
      ProcessingStage.Processed);
    t.title = rec.topic;
    t.dateStart = rec.startTime;
    t.duration = rec.durationSeconds;
    t.attendees = rec.participants.map((p) => ({
      email: p.email,
      friendlyName: p.name,
    }));
    t.transcript = { title: 'Transcript',
                     text: rec.transcriptText };
    return t;
  });

  return {
    items,
    metadata: {},
    pagination: {
      cursor: page.nextPageToken || undefined,
      hasMore: !!page.nextPageToken,
      pageSize: items.length,
    },
  };
};

Going live

A provider ships like any small web service. The checklist:

  1. Test locally. Run pnpm dev, hit the health check, then exercise a sync handler directly with curl - the SDK validates the body exactly as the platform would, so a hand-built request is a faithful test (the panel on the right shows one).
  2. Deploy. Build the template's Dockerfile and run it anywhere that serves HTTPS. Set the three environment variables. The template also includes a GitHub Actions workflow if you want CI to build and push the image.
  3. Register. Submit your provider definition with instanceUrl pointing at your deployment. From this moment the platform knows how to reach you.
  4. Connect and watch. Connect your own account from Noded's integrations page. Within a few minutes the first syncs fire. Watch your logs: you should see the connect round trip, then a steady rhythm of sync calls.

Before you call it done, walk the failure paths once on purpose: revoke your vendor token and confirm a CredentialsExpiredError shows up (and that reconnecting heals it), and run a big backfill to confirm the cursor keeps moving page after page.

Ship checklist

CheckWhy
externalId stabilityRerun a sync twice - the second run should report all items unchanged, none created
Cursor round tripA multi-page backfill completes; no silent stop after page one
401 mappingVendor 401s become CredentialsExpiredError, never a plain 500
Rate limitsYou throttle vendor calls; a rate-limited page returns partial data with a warning
Logo in /publicThe iconUrl in your definition resolves
BIGFOOT_API_KEY setThe server refuses to start without it - that refusal is your friend
Requesttest a sync by hand
curl -s http://localhost:5000/sync/transcription \
  -H "Authorization: Bearer $BIGFOOT_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "connectedProvider": {
      "connectionId": "local-test",
      "provider": "skylark",
      "application": "calls",
      "credential": {
        "accessToken": "sky_at_9f8e7d...",
        "email": "you@yourcompany.com"
      }
    },
    "transcriptionFilter": {
      "dateStart": 1756400400000,
      "dateEnd": 1757005200000,
      "limit": 5
    }
  }'
Dockerfilefrom the template
FROM node:24-alpine
WORKDIR /app
COPY . .
RUN corepack enable && pnpm install && pnpm build
EXPOSE 5000
CMD ["node", "dist/index.js"]