Skip to main content
Logic functions run sandboxed in short-lived Node.js processes — once a run finishes, nothing it kept in memory survives. When you need to remember something between runs (cache an expensive API response, store a cursor for incremental syncs, debounce work, or hand state from one function to another), persist it in the workspace database. You don’t need a dedicated storage primitive for this: a small technical object with a key field and a value field gives you a durable key-value store, scoped to the workspace, queryable through the same typed API client you already use for records.
  ┌─────────────────┐   set(key, value)    ┌──────────────────────────┐
  │ Logic function  │ ───────────────────▶ │ "KV Store" object        │
  │ (your handler)  │ ◀─────────────────── │  key (unique)  │  value  │
  └─────────────────┘   get(key)           └──────────────────────────┘

Define the store object

Declare a custom object with two fields — key (a unique TEXT) and value (a RAW_JSON so you can store any JSON-serializable payload). See Objects for the full defineObject reference.
src/objects/kv-store.object.ts
import { defineObject, FieldType } from 'twenty-sdk/define';

export const KV_STORE_UNIVERSAL_IDENTIFIER =
  '2f1c8a90-3b6d-4e2a-9c47-7d0e5a1b9f33';
export const KV_STORE_KEY_FIELD_UNIVERSAL_IDENTIFIER =
  '4a7e2d11-9c83-4f60-b5a2-1e6c8d0f4b21';
export const KV_STORE_VALUE_FIELD_UNIVERSAL_IDENTIFIER =
  '8b3f6c02-5d19-47ae-9f31-2c4a7e0b6d58';

export default defineObject({
  universalIdentifier: KV_STORE_UNIVERSAL_IDENTIFIER,
  nameSingular: 'kvStore',
  namePlural: 'kvStores',
  labelSingular: 'KV Store',
  labelPlural: 'KV Store',
  description: 'Key-value storage for logic functions',
  icon: 'IconDatabase',
  fields: [
    {
      universalIdentifier: KV_STORE_KEY_FIELD_UNIVERSAL_IDENTIFIER,
      name: 'key',
      type: FieldType.TEXT,
      label: 'Key',
      description: 'Unique lookup key',
      icon: 'IconKey',
    },
    {
      universalIdentifier: KV_STORE_VALUE_FIELD_UNIVERSAL_IDENTIFIER,
      name: 'value',
      type: FieldType.RAW_JSON,
      label: 'Value',
      description: 'Stored JSON payload',
      icon: 'IconJson',
    },
  ],
});

Enforce key uniqueness

Add a unique index on key so the same key can never have two rows. This is the recommended primitive for uniqueness — see Data → Unique indexes.
src/indexes/kv-store-key.index.ts
import { defineIndex } from 'twenty-sdk/define';
import {
  KV_STORE_UNIVERSAL_IDENTIFIER,
  KV_STORE_KEY_FIELD_UNIVERSAL_IDENTIFIER,
} from '../objects/kv-store.object';

export default defineIndex({
  universalIdentifier: 'c0d4e8f2-6a1b-4c93-8e57-3f9a2d0b7e14',
  objectUniversalIdentifier: KV_STORE_UNIVERSAL_IDENTIFIER,
  isUnique: true,
  fields: [
    {
      universalIdentifier: 'c0d4e8f2-6a1b-4c93-8e57-3f9a2d0b7e15',
      fieldUniversalIdentifier: KV_STORE_KEY_FIELD_UNIVERSAL_IDENTIFIER,
    },
  ],
});

Read and write from a logic function

Wrap the object behind a few small helpers so the rest of your code reads like a key-value API — get, set, and del. They use CoreApiClient, which is generated from your workspace schema and fully typed against the kvStore object.
src/logic-functions/handlers/kv-store.ts
import { CoreApiClient } from 'twenty-client-sdk/core';
import { isDefined } from 'twenty-sdk/utils';

const client = new CoreApiClient();

// Look up a single row by its key.
const findByKey = async (key: string) => {
  const { kvStores } = await client.query({
    kvStores: {
      __args: { filter: { key: { eq: key } }, first: 1 },
      edges: { node: { id: true, value: true } },
    },
  });

  return kvStores.edges[0]?.node;
};

// Read a value. Returns undefined when the key is missing.
export const get = async <TValue>(key: string): Promise<TValue | undefined> => {
  const row = await findByKey(key);

  return isDefined(row) ? (row.value as TValue) : undefined;
};

// Write a value. Creates the row on first write, updates it afterwards (upsert).
export const set = async (key: string, value: unknown): Promise<void> => {
  const existing = await findByKey(key);

  if (isDefined(existing)) {
    await client.mutation({
      updateKvStore: {
        __args: { id: existing.id, data: { value } },
        id: true,
      },
    });
    return;
  }

  await client.mutation({
    createKvStore: {
      __args: { data: { key, value } },
      id: true,
    },
  });
};

// Delete a value. No-op when the key is missing.
export const del = async (key: string): Promise<void> => {
  const existing = await findByKey(key);

  if (isDefined(existing)) {
    await client.mutation({
      deleteKvStore: { __args: { id: existing.id }, id: true },
    });
  }
};
The unique index protects against duplicates, but two runs writing the same new key at the same instant can still race between the lookup and the create. Treat a create that fails on the uniqueness constraint as “someone else won” — catch it and re-read, or retry as an update.

Use it: cache an expensive call

A typical use is caching a slow or rate-limited third-party response so repeated runs reuse it instead of paying the cost every time.
src/logic-functions/getExchangeRate.logic-function.ts
import { defineLogicFunction } from 'twenty-sdk/define';
import { get, set } from './handlers/kv-store';

const ONE_HOUR_MS = 60 * 60 * 1000;

type CachedRate = { rate: number; fetchedAt: number };

const handler = async (params: { from: string; to: string }) => {
  const cacheKey = `exchange-rate:${params.from}:${params.to}`;
  const cached = await get<CachedRate>(cacheKey);

  if (cached && Date.now() - cached.fetchedAt < ONE_HOUR_MS) {
    return { rate: cached.rate, cached: true };
  }

  const response = await fetch(
    `https://api.example.com/rate?from=${params.from}&to=${params.to}`,
  );
  const { rate } = (await response.json()) as { rate: number };

  await set(cacheKey, { rate, fetchedAt: Date.now() });

  return { rate, cached: false };
};

export default defineLogicFunction({
  universalIdentifier: 'd9b2f4e6-1c83-4a07-9e52-6b1d3c8a0f47',
  name: 'get-exchange-rate',
  timeoutSeconds: 10,
  handler,
});

Patterns & tips

  • Namespacing. Prefix keys to keep different concerns apart and to make bulk lookups easy — sync-cursor:linear, cache:exchange-rate:USD:EUR, lock:nightly-report. Filter with key: { like: 'cache:%' } to list or clear a whole namespace.
  • Expiry (TTL). The store has no built-in expiration. Store a timestamp inside the value (as in the cache example) and check it on read, or add a DATE_TIME field and periodically clear stale rows from a cron-triggered function.
  • What to store. RAW_JSON holds any JSON-serializable value — numbers, strings, arrays, objects. Keep entries small; this is for coordination and caching, not large blobs or files. For files, use a FILES field and uploadFile.
  • Visibility & permissions. Rows live in the workspace database like any other record, so they’re queryable through the API and respect your app’s role. To keep the store out of the main UI, leave it off your navigation menu.
  • Scoping to a record. Need per-record state instead of global keys? Add a relation from the store object to the target object rather than encoding the id into the key.
This is a convention, not a separate feature — the “KV Store” is just a regular custom object you define and query with the standard API. That means it benefits from the same sync, permissions, and tooling as the rest of your app’s data.