Skip to main content
Blog
Next

From Coda Docs to AI Memory: Pages, Tables and an Export API That Makes You Wait

•

Building a Coda connector for cognee, and the live test that showed my tables were never being updated.

Pages are exported as markdown, each table becomes one document, and only what changed is sent again

Coda docs mix two kinds of knowledge in one place: written pages, like a launch plan or meeting notes, and tables, like a task tracker. Both hold answers to questions such as “What is blocking the launch?”, but finding them means opening the right doc, the right page, and scrolling the right table.

For Mergetober, cognee’s integration hackathon, I built a Coda connector for issue #4719. It syncs pages and tables from Coda docs into cognee. The pull request is cognee-community #407.

cognee and dlt, briefly

cognee is an open-source memory layer for AI agents. You give it documents with cognee.remember(...) and ask questions with cognee.recall(...). dlt is a Python data loading library; the connector is a dlt resource that yields documents, plus "tombstones" that tell cognee what to forget.

import os
import cognee
from cognee_community_connector_coda import coda_source
await cognee.remember(
coda_source(doc_ids=["<doc id>"], token=os.environ["CODA_API_TOKEN"]),
dataset_name="coda",
primary_key="id",
write_disposition="merge", # REQUIRED, the add pipeline defaults to "replace"
max_rows_per_table=0,
self_improvement=False,
)
answer = await cognee.recall("What is blocking the launch?", datasets=["coda"])

The doc id is the part after _d in a doc's URL: coda.io/d/Launch-plan_dAbCDeFGH has the id AbCDeFGH. A read-only API token restricted to the docs you sync is enough. You can also leave out doc_ids and sync every doc the token can see, narrowed by workspace or folder.

Pages: an export you have to wait for

Coda does not return a page’s content directly. To get a page as markdown, you go through an export flow:

  1. Start an export with a POST. Coda answers with a request id.
  2. Poll the export’s status until it says complete.
  3. Download the result from a short-lived signed link.

The connector polls with a growing delay (1 second, then 2, 4, up to 5), and gives up after two minutes, because a stuck export should not hang the whole sync. The signed download link carries its own permission, so the API token is not sent to it. Sending a token anywhere it is not needed is a habit worth avoiding.

Each page becomes one document named page:<doc>:<page>, starting with the doc's name so the context is clear. Hidden pages and sync pages (which mirror a page of another doc) are opt-in, and embed pages are skipped.

Tables: one document, not one per row

This was the most interesting design decision, and the issue specifically asked about it. How should a table become memory?

Every row becomes a labeled block inside a single table document.

I considered three options:

  • Rows as graph nodes directly. This skips cognee’s entity extraction, so the meaning in the text would be lost.
  • One document per row. Each row would cost a full cognify pass, and a row like “Fix login, Sam, Done” means little without the rows around it.
  • One document per table. Each row is rendered as a labeled block, ## Row: <display value> followed by - Column: value lines.

I went with one document per table. The rows keep each other’s context, and a changed table costs one cognify, not one per row. The display column becomes the row heading and is not repeated in the list below it. Empty cells are skipped, and lists inside a cell are joined into text.

Views are excluded on purpose. A view shows rows of a table that is already synced, so including it would put the same rows into memory twice.

Knowing what changed, without changedSince

Some APIs let you ask “what changed since this time?” Coda’s current API (spec v1.6.0) does not offer that. So each run lists the pages and tables of every doc, which is cheap, and compares each item’s updatedAt with the value saved from the last run:

  • A changed page is exported again. An unchanged one costs nothing.
  • A changed table has its columns and rows read again.
  • A renamed doc re-renders all its documents, since each one starts with the doc’s name.

That was the plan. Then I tested it live.

The live test that found a silent bug

I ran the connector against a real Coda doc with four pages and a task table. The first sync worked. Editing a page worked. Then I added a row to the table and synced again, and nothing happened.

The cause: the table listing does not include updatedAt or displayColumn. Only each table's own endpoint has them. My code compared the listing's missing value with the saved missing value, saw no difference, and never read the table again after its first sync. Every test passed, because my test fake had put updatedAt in the listing, which the real API does not do.

The fix reads each table’s own endpoint, one request per table per run, to get the real updatedAt. And the test fake now returns listings shaped exactly like the live API, so a fake that is too generous cannot hide this kind of bug again.

The live run after the fix:

Forgetting only what is really gone

A page or table missing from its doc is forgotten, and so is a doc that answers 404 or 410, leaves the doc listing, or is removed from doc_ids. Around that rule, there are several safety checks, and some of them came from an independent review of my code:

  • Access removed is not deletion. A doc, page or table that answers 403 is left unchanged and counted as unreadable.
  • A 404 from a listing is double-checked. If a doc’s page or table listing answers 404, the connector asks the doc itself before forgetting anything. Only if the doc also answers 404 or 410 is it forgotten. Before this fix, one 404 from a listing would have deleted a perfectly readable doc and everything in it.
  • Empty answers are confirmed. Every Coda doc has at least one page, so an empty page listing is never trusted. An empty table listing keeps the tables until the next run confirms it, because a doc can lose all its tables, but one bad answer should not delete them.
  • Partial progress is kept. If a run stops partway through a big doc, because of a rate limit or the request budget, the pages and tables it already finished are kept. Before, a doc too big for one run could never make progress.

A detail about the API’s new home

While building this, I noticed Coda’s API is now also served by Superhuman Docs. Both addresses answer today, so the connector has a configurable base_url with https://coda.io/apis/v1 as the default. If the address ever changes, users can switch without waiting for a new release.

What this connector taught me

  1. Check what the listing actually contains. A field that exists on a detail endpoint may not exist in the list. My fake assumed it did, and that hid a bug that broke every table update.
  2. Pick the document size on purpose. One document per table was the right balance between context and cost here, and it was worth writing down why.
  3. A 404 can mean different things. A missing child listing is not proof that the parent is gone. Ask the parent.
  4. Live testing is not optional. The most important bug in this connector only appeared against a real doc.

The connector has 68 tests that run on Python 3.10, 3.11 and 3.13 without a Coda account.

Links

Built for Mergetober 2026, cognee’s open-source integration hackathon.

Related Posts

View all posts →

Rajdeep
Singhio

Full Stack Developer & AI Engineer

© Copyright

RJDP-2026Built with Next.js & Tailwind