> ## Documentation Index
> Fetch the complete documentation index at: https://docs.tckg.factagora.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Migrate from Supabase

> For teams that keep their knowledge graph in their own Postgres tables today. Move the graph to tckg without a big-bang cutover.

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

| Supabase | tckg | Note |
| - | - | - |
| One `tkgs` row | One **space**: `tckg:<tkgs.id>` | Title, visibility, folder, counts stay in Supabase |
| One `tkg_nodes` row | One **node** | Mapping below |
| One `tkg_edges` row | One **edge** | All 11 edge types exist in tckg under the same names |
| `claims`, `predictions` bodies | Stay | Nodes point back through `payload.factblock_id` |
| `user_beliefs` | Not moved | Already absorbed into `tkg_nodes.stance` |
| `tkg_nodes` DELETE | **None** | See "Deletes" below |

### 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](/concepts#grouping-data)).

| You would have made a table for... | In tckg | For factagora.ai |
| - | - | - |
| Each user's or agent's graph | `space`: whose memory this is. Reads, search, and export are scoped by it. It is a wall: nothing crosses it unless you omit it | One `tkgs` row is one space, `tckg:<tkgs.id>`. An agent's own TKG is `tckg:<id>` too (identity is `tkgs.agent_id`, so the prefix can stay `tkg:`) |
| A new kind of record (articles, observations, scenarios) | `kind`: the record type. Core values plus anything you define; unknown values are preserved | `tkg_nodes.factblock_type` becomes `claim` or `prediction`; a future "news article" is `kind: "article"`, no schema change |
| A topic or domain split | `category`: a topic label. Filters reads, narrows search candidates, scopes export. Not a wall: causal expansion may cross topics | `claims.category` as is (`macro`, `equities`, ...) |
| Your own columns | `payload`: free JSON. Stored and returned, never interpreted | `stance`, `note`, `factblock_id`, `annotation` |
| A separate customer | `tenant`: hard isolation, chosen by the API key | app.factagora.com and factagora.ai each have their own key, so their own tenant ([Keys](#keys-one-per-app)). Inside an app, `space` prefixes (`tckg:`, `agent:`, `user:`) do the grouping |

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

```
tkg_nodes.id              -> id
tkg_nodes.factblock_type  -> kind        ('CLAIM' -> 'claim', 'PREDICTION' -> 'prediction')
claims.title (title_en)   -> statement
claims.category           -> category
tkg_nodes.stance, note    -> payload.stance, payload.note
tkg_nodes.factblock_id    -> payload.factblock_id
claims.annotation         -> payload.about (when), payload.source   (only when present)
tkg_nodes.added_at        -> asserted_at, valid_from
```

`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_nodes.id (source)     -> source_id
tkg_nodes.id (target)     -> target_id
tkg_edges.edge_type       -> edge_type   (as is)
tkg_edges.confidence      -> confidence
tkg_edges.lag             -> lag         (only values that parse as an interval; see below)
tkg_edges.created_at      -> asserted_at, valid_from
```

<Warning>
  `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.
</Warning>

## 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.

```bash theme={null}
curl -s -X POST $TCKG/v1/memories -H 'content-type: application/json' -d '{
  "space": "tckg:7f3c...-tkgs-uuid",
  "backfill": {"declared_captured_at": "2026-05-25T13:02:11Z", "reason": "tkg_nodes added_at 2026-05-25"},
  "nodes": [
    {"id": "a1b2...-tkg_nodes-uuid", "kind": "claim",
     "statement": "The Fed raises interest rates", "category": "macro",
     "payload": {"factblock_id": "c9d8...-claims-uuid", "stance": "BELIEVE", "note": null},
     "asserted_at": "2026-05-25T13:02:11Z", "valid_from": "2026-05-25T13:02:11Z"}
  ],
  "edges": [
    {"source_id": "a1b2...", "target_id": "e5f6...", "edge_type": "CAUSES",
     "confidence": 0.7, "lag": "20 days",
     "asserted_at": "2026-05-25T13:40:00Z", "valid_from": "2026-05-25T13:40:00Z"}
  ]
}'
```

<Tip>
  **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.
</Tip>

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.

```bash theme={null}
# a date before that TKG existed: items empty, masked has counts
curl -s "$TCKG/v1/memories?space=tckg:7f3c...&as_of=2026-05-01"
# now: must equal Supabase node_count minus the refused rows
curl -s "$TCKG/v1/memories?space=tckg:7f3c...&as_of=$(date -u +%Y-%m-%dT%H:%M:%SZ)" | jq '.items | length'
```

## 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.

| Supabase query today | tckg |
| - | - |
| All nodes of a TKG | `GET /v1/memories?space=tckg:<id>&as_of=now` |
| One node | `GET /v1/memories/<tkg_nodes.id>?as_of=now` |
| This node's causes and effects | `GET /v1/memories/<id>/why?as_of=now` |
| Related nodes by keyword | `POST /v1/search {query, space, as_of, depth}` |
| "What did this person believe back then" | The same call with `as_of=<then>`. **Supabase has no such query** |

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

| Stays | Why |
| - | - |
| `tkgs` (title, visibility, folder, forked\_from) | App metadata. tckg only knows the space string |
| `claims`, `predictions`, `factblock_edges` | FactBlock bodies and the shared global graph. Nodes point to them through `payload.factblock_id` |
| `position_x`, `position_y` | Canvas coordinates. Not memory |
| RLS | tckg scopes by space within a tenant. "Who may see which TKG" is the app's decision |

| Goes (once the cutover is done) | Replaced by |
| - | - |
| Reads of `tkg_nodes`, `tkg_edges` | tckg reads |
| Sampling and snapshots to reconstruct a past state | `as_of` |
| A separate log for stance changes | `SUPERSEDES` plus `why` |

## 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.

```bash theme={null}
curl -s -X POST $TCKG/v1/memories -H "Authorization: Bearer $TCKG_TOKEN" -H 'content-type: application/json' -d '{
  "space": "user:randy",
  "backfill": {"declared_captured_at": "2024-03-20T12:00:00Z", "reason": "youtube backfill"},
  "messages": [{"content": "The Fed will keep raising rates this year. That means bond yields keep climbing, so I would stay out of long bonds.",
                "observed_at": "2024-03-20T12:00:00Z", "source_description": "youtube"}]}'
```

* `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](/mcp#end-users-one-worldview-one-address).

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](/api-reference/write-memories) for what comes out of a message.

## Not yet

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


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.