Skip to main content
This guide is written for factagora.ai, which keeps TKGs (a user’s or an agent’s worldview graph) in Supabase tables tkgs, tkg_nodes, tkg_edges. The same steps apply to any team with a similar layout. Only the graph moves. Users, billing, FactBlock bodies (claims, predictions), canvas coordinates, and folders stay in Supabase. Real users are on the service, so the order is dual write, then cut reads over one at a time, then compare, then remove the old path. Nothing switches at once.

What goes where

Grouping without tables

In Supabase you would reach for a new table when a new kind of data shows up. tckg has no tables to create. Four fields do that job, and every read takes them as arguments (Concepts). Two rules follow from this. Filtering on fields inside payload is not indexed, so if a screen needs “all nodes where payload.stance is BELIEVE”, ask for a filter argument instead of working around it (that is how category was added). And a change of stance is not an update to payload.stance: it is a new node plus a SUPERSEDES edge, because the old stance is the history you came here to keep.

Node ids

A node id is unique within a tenant. The same FactBlock sits in many TKGs, so using factblock_id as the id collides. Use tkg_nodes.id (a different uuid per TKG) as is. The way back to the FactBlock is payload.factblock_id.

Nodes

asserted_at is when this user took this stance, not when the FactBlock came into the world. Hence tkg_nodes.added_at, not claims.created_at.

Edges

tkg_edges.lag is TEXT, so nothing ever guarded it. In a real export, 2 of 541 rows held a sentence instead of a duration (“The ‘down first’ crisis triggers…”). tckg does not guess; such rows come back in refused. Those two go in as mechanism instead.

Step 1. One-time import (backfill)

Existing rows were created at added_at or created_at, but tckg learns them today. Inserted plainly, every past as_of would show nothing. So declare a backfill. One request is one batch, and a batch has one declared instant. Recommended granularity: one request per TKG per calendar day, with declared_captured_at set to that day’s max(added_at). One batch per row is exact but turns rows into requests. Per day loses only the ordering inside a day.
Keep the refused list. Every row that did not go in is there with its id and reason. The import is done when refused is empty or fully explained.
Resending the same request is safe: existing rows come back as warned: already_remembered and nothing is written. An edge that arrives before its nodes is refused with missing_endpoint. Put the nodes in the same request or send them first.

Check

Pick one migrated TKG and read it as of two instants.

Step 2. Dual write

Wherever the app writes tkg_nodes or tkg_edges (the tkgbuilder/capture route, for example), also call POST /v1/memories. No backfill, asserted_at = now. Keep the Supabase write. All reads still come from Supabase.
  • Await the response, but a tckg failure must not fail the user’s request. Queue the failed body and resend; resends are safe through already_remembered.
  • Where an upsert used to be, just POST. Same content gives a warning, different content gives a new row. A changed stance (BELIEVE to DISBELIEVE) is a new node id plus a SUPERSEDES edge. Nothing is overwritten.

Step 3. Cut reads over one at a time

Pick one screen or API route. Produce the old answer and the tckg answer from the same input and compare. Show the old path until the diff is zero. For “now”, pass the call’s own timestamp as as_of. There is no default; omitting it is a 400.

Deletes

tkg_nodes has DELETE. tckg does not, and answers 501. During dual write, delete in Supabase and do nothing in tckg. The node stays in tckg, and “this person believed this at that time” remains true as a record. Making something invisible is for v1.x invalidate. Until then, keep the delete button’s reads on Supabase.

What stays and what goes

Keys: one per app

The two apps call the same address with different keys, and the key is the tenant. factagora.ai keeps the token it already has. app.factagora.com gets its own key (handed out through the password manager). Rows written with one key never appear in reads with the other: same URL, same database, but the server stamps every row with the key’s tenant and every read is scoped to it. app.factagora.com holds other people’s data, which is why the two are apart from day one. Inside an app, keep using space for users, agents and datasets. Do not ask for a key per user.

Natural-language writes

POST /v1/memories also takes messages. Instead of the nodes and edges the app builds, send the text and when it was said; the server extracts the blocks (claim, prediction), the causal links the speaker argued between them, and the entities they mention (a company, an asset, a person). Gemini does the extraction; a message takes a few seconds and runs inside the request.
  • observed_at is when the words were said. Every block from the message is asserted at that instant, and the extraction knows nothing after it.
  • For past material, add backfill as well, so the blocks are also known then. Without it, a 2024 transcript ingested today is asserted in 2024 but known today, and as_of=2024-06-01 reads hide it.
  • The response’s episodes lists everything the message produced. The same statement sent again later resolves onto the existing block; no duplicate.
One MCP address per worldview. A worldview is a space, so an agent that should live inside one connects to /mcp/factagora/tckg:<id> and can reach nothing else. For an end user’s own client, the app shows that address: a tckg:<uuid> space opens with no credential on the hosted deployment (the address is the secret), and the app key never leaves the server. See Connect an agent. For factagora.ai this is the tkgbuilder/ingest route: the transcript it fetches can go straight in as one message per video, observed_at = the video’s publish date. The capture route keeps sending structure, since a human has already reviewed those cards. See Write memories for what comes out of a message.

Not yet

  • SDK. Plain HTTP. A TypeScript SDK comes after.