TypeScript Conversation Forking

This guide adds conversation forking metadata to the chat path.

New to forking concepts? Read Forking first.

Prerequisites

Starting checkpoint: typescript/examples/vecelai/doc-checkpoints/03-with-history

Add Fork Parameters to Chat

Checkpoint 04 adds fork query parsing:

app.ts
  const forkedAtConversationId =
    (req.query.forkedAtConversationId as string | undefined) ?? null;
  const forkedAtEntryId =
    (req.query.forkedAtEntryId as string | undefined) ?? null;

What changed: The endpoint accepts optional forkedAtConversationId and forkedAtEntryId query parameters.

Why needed: These values identify the source conversation and exact branch point for creating a new forked branch.

Pass Fork Metadata to the USER History Append

app.ts
  const result = await withMemoryService(
    {
      ...memoryServiceConfig,
      conversationId,
      authorization,
      memoryContentType: "vercelai",
      userText: userMessage,
      forkedAtConversationId,
      forkedAtEntryId,
    },
    async (contextMemory) => {

What changed: The chat handler now passes fork metadata (forkedAtConversationId, forkedAtEntryId) into withMemoryService(...), so only the initial USER history write carries the fork link.

Why needed: Memory Service creates the fork relationship from that first USER history append in the new conversation; later writes should not repeat fork metadata.

Proxy the Fork Listing Endpoint

app.ts
  memoryServiceConfigFromEnv,
  withMemoryService,
  withProxy,
} from "@chirino/memory-service-vercelai";

const app = express();
app.use(express.text({ type: "*/*" }));

What changed: Adds memoryServiceConfigFromEnv(...), withProxy, and a shared memoryServiceConfig.

Why needed: Fork-listing routes proxy Memory Service APIs through the app layer, and the proxy now receives explicit SDK config instead of reading env vars implicitly.

app.ts
function asNumber(value: unknown): number | null {
  if (typeof value !== "string" || value === "") {
    return null;
  }
  const parsed = Number(value);
  return Number.isFinite(parsed) ? parsed : null;
}

What changed: Adds a small query coercion helper for paging params.

Why needed: Query values arrive as strings; the proxy API expects numeric limit or null.

app.ts
app.get("/v1/conversations/:conversationId/forks", async (req, res) => {
  await withProxy(req, res, memoryServiceConfig, (proxy) =>
    proxy.listConversationForks(req.params.conversationId, {
      afterCursor: (req.query.afterCursor as string | undefined) ?? null,
      limit: asNumber(req.query.limit),
    }),
  );
});

What changed: Exposes GET /v1/conversations/:conversationId/forks and forwards pagination args.

Why needed: Clients can discover and render branch trees without calling Memory Service directly.

Try It With Curl

Define a helper to get a bearer token for bob:

function get-token() {
  curl -sSfX POST http://localhost:8081/realms/memory-service/protocol/openid-connect/token \
    -H "Content-Type: application/x-www-form-urlencoded" \
    -d "client_id=memory-service-client" \
    -d "client_secret=change-me" \
    -d "grant_type=password" \
    -d "username=bob" \
    -d "password=bob" \
    | jq -r '.access_token'
}

Create the source conversation turn:

curl -NsSfX POST http://localhost:9090/chat/c4d8a5f1-2f7b-4a6b-9e03-3a2f66e3b111 \
  -H "Content-Type: text/plain" \
  -H "Authorization: Bearer $(get-token)" \
  -d "Hello from the root conversation."

Example output:

Sure, I can help with that.

Read the branch point entry id from Memory Service:

curl -sSfX GET http://localhost:8082/v1/conversations/c4d8a5f1-2f7b-4a6b-9e03-3a2f66e3b111/entries?channel=history \
  -H "Authorization: Bearer $(get-token)" \
  -H "X-API-Key: agent-api-key-1" | jq

Example output:

{
  "data": [
    {
      "id": "ff1c0c7b-f76f-495d-b73d-b88bd83de88f"
    }
  ],
  "afterCursor": null
}

Create the forked conversation by calling chat with fork metadata:

curl -NsSfX POST "http://localhost:9090/chat/e5b9c1d2-8a4e-4c1a-b8d1-1d7f8e2a9222?forkedAtConversationId=c4d8a5f1-2f7b-4a6b-9e03-3a2f66e3b111&forkedAtEntryId=${FORK_ENTRY_ID}" \
  -H "Content-Type: text/plain" \
  -H "Authorization: Bearer $(get-token)" \
  -d "Continue from this fork."

Example output:

{
  "afterCursor": null,
  "data": [
    {
      "id": "ff1c0c7b-f76f-495d-b73d-b88bd83de88f",
      "conversationId": "c4d8a5f1-2f7b-4a6b-9e03-3a2f66e3b111",
      "userId": "bob",
      "clientId": "checkpoint-agent",
      "channel": "history",
      "contentType": "history",
      "createdAt": "2026-03-06T14:59:40.352461Z",
      "content": [
        {
          "role": "USER",
          "text": "Hello from the root conversation."
        }
      ]
    },
    {
      "id": "a370e1b2-953a-4767-aa38-69638621ff00",
      "conversationId": "c4d8a5f1-2f7b-4a6b-9e03-3a2f66e3b111",
      "userId": "bob",
      "clientId": "checkpoint-agent",
      "channel": "history",
      "contentType": "history",
      "createdAt": "2026-03-06T14:59:40.382506Z",
      "content": [
        {
          "role": "AI",
          "text": "I am a TypeScript memory-service demo agent."
        }
      ]
    }
  ]
}

List forks through the proxied endpoint:

curl -sSfX GET http://localhost:9090/v1/conversations/c4d8a5f1-2f7b-4a6b-9e03-3a2f66e3b111/forks \
  -H "Authorization: Bearer $(get-token)" | jq

Example output:

{
  "data": [
    {
      "conversationId": "c4d8a5f1-2f7b-4a6b-9e03-3a2f66e3b111"
    },
    {
      "conversationId": "e5b9c1d2-8a4e-4c1a-b8d1-1d7f8e2a9222"
    }
  ],
  "afterCursor": null
}

Next Steps