Skip to content

Pre-GA Design Partner and Early Access only. Request access

Helix Docs

Key-value store

Store JSON values per project with ctx.kv(): getItem, setItem, hasItem, and removeItem. In memory locally, persistent once deployed.

For developers Updated Aug 5, 2026
View as Markdown

ctx.kv() gives every function a project-scoped key-value store for state that has to survive between requests: sync cursors, cached lookups, session records, feature flags. It has four methods (getItem, setItem, hasItem, removeItem) and holds any JSON-serializable value up to 250 KB. This is the data service Helix ships today.

One store per project

Call ctx.kv() with no arguments. Every function in the project, and every execution of those functions, reads and writes the same store:

// functions/api/v1/state.get.ts
import { defineFunction } from '@trayai/helix-sdk';

export default defineFunction(async (ctx) => {
  const kv = ctx.kv();
  const cursor = await kv.getItem<string>('sync:salesforce:cursor');
  return { cursor };
});

Two functions that write cursor write the same key. Namespace your keys so they don’t collide.

The four methods

interface KeyValueStore {
  // Read a value. Returns null when the key is missing.
  getItem<T = unknown>(key: string): Promise<T | null>;

  // Write a value. Overwrites whatever was at that key.
  setItem(key: string, value: unknown): Promise<void>;

  // Check whether a key exists, without reading the value.
  hasItem(key: string): Promise<boolean>;

  // Delete a key.
  removeItem(key: string): Promise<void>;
}

That is the entire surface. Type the read with the generic parameter:

const state = await kv.getItem<{ cursor: string; page: number }>('sync:state');
// state is { cursor: string; page: number } | null

if (state) {
  ctx.log.info('Resuming sync', { cursor: state.cursor });
}

Listing keys is not supported

There is no getKeys(), no prefix scan, and no way to enumerate what the store holds. You can only read a key you already know the name of.

When you need to iterate a set of records, keep your own index under a known key:

// functions/api/v1/sessions.post.ts
import { defineFunction } from '@trayai/helix-sdk';
import { z } from 'zod';

export const input = z.object({
  sessionId: z.string().min(1),
  userId: z.string().min(1),
});

export default defineFunction<typeof input>(async (ctx) => {
  const kv = ctx.kv();
  const { sessionId, userId } = ctx.input;

  await kv.setItem(`session:${sessionId}`, { userId, startedAt: Date.now() });

  // Maintain the list of ids yourself, because the store cannot list them
  const index = (await kv.getItem<string[]>('session:index')) ?? [];
  if (!index.includes(sessionId)) {
    await kv.setItem('session:index', [...index, sessionId]);
  }

  return { sessionId };
});

Read, modify, write is not atomic. Two executions updating the same index at the same moment can lose one of the two writes, so keep index keys narrow (one per user, one per day) rather than one global list that every request touches.

Key rules

Keys are validated on every call. A key that breaks a rule throws an HTTP 400 rather than writing or reading anything.

RuleValidInvalid
At most 1024 UTF-8 bytesuser:123:prefsA 2 KB key
Not emptya''
No path traversalcache:reportscache:../secrets
No ? or #search:latestsearch?q=x, page#top
No control charactersjob:42job:\n42
No leading or trailing /files/report/files/report, files/report/
No empty path segmentsa/ba//b

Byte length, not character length: multi-byte characters count for more than one. café:1 is six characters and seven bytes.

The convention is a colon-delimited namespace, most general segment first:

await kv.setItem('session:abc', { userId: 'u_1' });
await kv.setItem('user:123:prefs', { theme: 'dark' });
await kv.setItem('cache:report:2026-08', { rows: 412 });

Because you cannot list keys, the namespace is for your own readability and for avoiding collisions between functions. Nothing in the platform reads the segments.

Value rules

Values must be JSON-serializable and at most 250 KB once serialized. Strings, numbers, booleans, objects, and arrays all work:

await kv.setItem('last_sync', '2026-08-01T10:30:00Z');
await kv.setItem('retry_count', 3);
await kv.setItem('sync_state', { cursor: 'abc123', page: 5, total: 2340 });
await kv.setItem('recent_ids', ['id1', 'id2', 'id3']);
await kv.setItem('maintenance_mode', false);

Values cross a JSON boundary on the way in and out, so you always get back a parsed copy rather than the same object reference. Anything that does not survive JSON.stringify (functions, class instances, BigInt, Date as a Date) cannot be stored. Serialize dates to ISO strings yourself. Oversized values throw an HTTP 400, the same as an invalid key.

Local development and production

The code is identical in both places; the store behind it is not.

helix devDeployed
BackendIn-memory, inside the dev server processPlatform key-value store
LifetimeLost on every restartPersists across deploys
SetupNoneProject must be provisioned

Restarting helix dev empties the store, so seed any state your handler expects instead of assuming a previous run left it there.

Persistence requires a provisioned project. Point the CLI at one, then deploy:

helix project set <projectId>
helix deploy

Worked example: an incremental sync cursor

// functions/api/v1/sync.post.ts
import { defineFunction } from '@trayai/helix-sdk';

export default defineFunction(async (ctx) => {
  const kv = ctx.kv();
  const key = 'sync:salesforce:cursor';

  // hasItem distinguishes "never synced" from "synced, cursor cleared"
  const firstRun = !(await kv.hasItem(key));
  const since = firstRun ? null : await kv.getItem<string>(key);

  ctx.log.info('Starting sync', { firstRun, since });

  const startedAt = new Date().toISOString();

  // ... fetch and process records changed since `since`

  await kv.setItem(key, startedAt);
  return { firstRun, since, cursor: startedAt };
});

Full method reference: ctx.kv. Current platform limits: limits and roadmap.

Loading search…

Jump to a section

tab to move · esc to close