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.
| Rule | Valid | Invalid |
|---|---|---|
| At most 1024 UTF-8 bytes | user:123:prefs | A 2 KB key |
| Not empty | a | '' |
| No path traversal | cache:reports | cache:../secrets |
No ? or # | search:latest | search?q=x, page#top |
| No control characters | job:42 | job:\n42 |
No leading or trailing / | files/report | /files/report, files/report/ |
| No empty path segments | a/b | a//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 dev | Deployed | |
|---|---|---|
| Backend | In-memory, inside the dev server process | Platform key-value store |
| Lifetime | Lost on every restart | Persists across deploys |
| Setup | None | Project 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.