KV and session
The workflow runtime injects local/server KV, PersistentValue, and conversation capabilities for each execution. The project's dataStorage.mode strictly limits which locations may be accessed.
await context.storage.local.kv.setValue("project-key", { count: 1 });
await context.storage.local.kv.conversation.setValue("conversation-key", true);
await context.storage.server.persistentValue.setValue("durable-key", { saved: true });
Use updateValue() for one read-modify-write value. Local storage updates it under the current namespace lock; a Server connection retries compare-and-set against the row revision, so concurrent increments are not silently lost. The updater may run more than once and must remain pure: do not send requests or cause side effects inside it. A local file backend's withLock() covers one namespace only. Access to another scope, project, or storage kind while holding it is rejected; split cross-namespace work into separate operations. On Windows, a brief EPERM or EACCES while creating an exclusive lock file is retried within the lock wait period; persistent failure still returns a timeout.
KV Scope
| Scope | Isolation dimension |
|---|---|
context.storage.<location>.kv | Current projectId. |
context.storage.<location>.kv.conversation | Current projectId + conversationId. |
context.storage.<location>.persistentValue | Current projectId, in a storage domain strictly separate from KV. |
context.storage.<location>.persistentValue.conversation | Current projectId + conversationId, still in the PersistentValue domain. |
location is local or server. Multiple entrypoints in one project share that location's project namespace. The entrypoint ID is not added to keys automatically; add a stable business prefix when entrypoint isolation is required. Store scope is only project | conversation. There is no global/workflow scope and no kv.workflow, persistentValue.workflow, context.kv, or context.persistentValue.
KV values must be JSON serializable and cannot be undefined. context.storage.local and context.storage.server always address separate datasets. A location forbidden by the declaration or unavailable from the host throws and never falls back.
Kanban exposes only current-project kv.getValue(key) and kv.setValue(key, value). It has no list/delete API and cannot access conversation scope. To isolate page state by named configuration, include getConfiguration().id || "default" in the key. Re-read after a configuration switch and use a request generation so a stale response cannot overwrite the new configuration.
Related-Project KV
Generic relationships are declared in package.json.workflowCode.projectInfo.relatedProjects on both projects:
{
"alias": "inventory",
"projectId": "f4ee23d7-abbb-4ba3-8baf-75b7c6c0b964",
"grantToRelatedProject": {
"read": { "mode": "all" },
"write": { "mode": "none" }
}
}
grantToRelatedProject means "allow the other project to access this project." Project A resolves B using A's alias, but A's effective access to B comes from B's grant to A. The relationship is confirmed only when B also points back to A's UUID; aliases may differ. A one-sided declaration is pending. It can be saved, published, and included in the assistant's code workspace, but grants no KV access and does not enter an included dependency group.
A rule mode is none | all | prefixes. prefixes requires 1 to 32 non-empty, unique prefixes, and a key is allowed when it starts with any one. A new relationship writes explicit read: all and write: none on both sides by default. Change each project's declaration separately for bidirectional writes or one-way access. getValue checks the target's read grant to the consumer; setValue checks write; updateValue checks both. Every access re-reads current declarations, so removing a relationship or narrowing a rule takes effect immediately.
An alias must match [a-z][a-z0-9_-]{0,63}. A project supports at most 32 relationships; aliases and projectId values must be unique within the project and cannot reference itself. KV access is direct only and never recurses from B to C. Related access exposes KV only, not the target's PersistentValue, Knowledge, conversation KV, list, or delete operations.
const inventory = context.storage.local.relatedProjects.get("inventory");
const current = await inventory.kv.getValue("stock:item-1");
await inventory.kv.setValue("stock:item-1", { count: 8 });
await inventory.kv.updateValue("stock:item-1", (value) => updateStock(value));
Resolution follows the selected location exactly: storage.local reads the target's local KV only, while storage.server reads Server KV only. A disallowed location or unavailable backend fails without fallback. CLI also requires repeatable --related-project alias=/absolute/project/path for every target and never scans parent or sibling directories.
Core workspace examples
The Core repository's workspace/workflow directory contains two runnable data-access groups. local-data-parent, local-data-child, and local-data-grandchild always use context.storage.local; server-data-parent, server-data-child, and server-data-grandchild always use context.storage.server. Every project has two independently runnable entrypoints: the default store accepts --content directly, saves it under a random UUID, and returns the id; get accepts only that --id and returns the original content. Both entrypoints accept an optional --alias for a directly related project and target the current project when it is omitted.
Each group declares adjacent relationships only. The parent connects to the child as child; the child connects to both sides as parent and grandchild; the grandchild connects to the child as parent. The examples explicitly grant bidirectional reads and writes on each direct relationship so they can verify current-project isolation for generated ids, bidirectional parent-child and child-grandchild access, and the absence of local/server fallback. The parent and grandchild have no direct relationship, so KV access through an undeclared alias fails instead of recursing through the child. Workspace structure snapshots and hierarchy-access tests continuously maintain these contracts.
On Server, a user who can read consumer project A may read B through A; writing also requires permission to run A. The user need not be a B member, but the bidirectional declaration and B's key grant to A must be valid. Opening or managing B directly, or bypassing A to request B's data, still checks B's own permissions. A related request supplies only consumer project UUID and alias; callers cannot choose the target UUID.
Kanban uses the same relationship and grant:
const inventory = window.workflowCodeKanban.relatedProjects.get("inventory");
const value = await inventory.kv.getValue("stock:item-1");
await inventory.kv.setValue("stock:item-1", { count: 8 });
const unsubscribe = inventory.kv.subscribe(({ alias, revision }) => {
scheduleRefresh(alias, revision);
});
Bridge requests carry only alias and key. The iframe cannot supply a target UUID, scope, target, or database path. subscribe() reports only alias and an opaque revision. Debounce events into a fresh getValue() and call the returned unsubscribe function on unload or configuration changes. An invalid relationship, read grant, or target immediately invalidates current subscriptions and later access. Desktop-local and Server KV remain independent, and project-group upload, publication, and download never copy KV.
| Data | Local CLI/Desktop directory | Description |
|---|---|---|
context.storage.local.kv | WORKFLOW_KV_STORE_DIR | Normal KV, conversation state, and other local data. |
context.storage.local.persistentValue | WORKFLOW_PERSISTENT_VALUE_STORE_DIR | Persistent values and the project Knowledge base. |
Local CLI/Desktop runs inject these directories into context.storage.local. context.storage.server uses the project UUID and an authenticated Server connection. A local run of a server project uses the server location as its host location; both defaults to the current host and still lets source code access the other location explicitly. Server runs provide only server storage. An unavailable location throws, and local and server data never synchronize automatically.
Last Write Source
KV and PersistentValue record the last write source, such as project, run, and conversation, when the host provides source information. The Server PostgreSQL KV viewer uses these fields to link a key back to its run log and conversation. The Desktop local data viewer also displays source information for local records.
Project Knowledge Base
The project Knowledge base stores Markdown documents in the top-level, current-project persistentValue at the selected location:
| scope | key | Content |
|---|---|---|
| project | knowledge.documents | v2 index metadata, total number of records, incrementing sequence, and first page ID. |
| project | knowledge.documents.page.<pageId> | Bounded page blocks of up to 128 document abstracts. |
| project | knowledge.documents.locator.<id> | Document to page block positioning record; tombstone after deletion. |
| project | knowledge.document.<id> | Single markdown document body; tombstone after deletion. |
Business workflow can manage documents through core helper:
const storage = context.storage.local;
const document = await workflow.createKnowledgeDocument(storage, {
title: "Runbook",
markdown: "# Runbook
Persistent notes.",
});
const result = await workflow.searchKnowledgeDocuments(storage, {
query: "Persistent",
documentId: document.id,
beforeLines: 1,
afterLines: 1,
});
The available methods are listKnowledgeDocuments, getKnowledgeDocument, createKnowledgeDocument, editKnowledgeDocument, deleteKnowledgeDocument, searchKnowledgeDocuments, and readKnowledgeDocumentLines. The cursor of listKnowledgeDocumentPage() is an opaque token that can only be passed back as is. The first read only accesses the metadata and the required page block, and does not read the body or scan the old page. Indexes are fixed to v2 page block format, old single array indexes are not read or migrated. These methods do not write the knowledge base to a normal CLI/Desktop local KV; the local knowledge base is written when the WORKFLOW_PERSISTENT_VALUE_STORE_DIR is set, and the server database is written when the server is running. The "local" and "server" knowledge base views of Desktop read their respective backends, and switching data sources will not transfer data. Create, edit, and delete are performed serially in the same PersistentValue namespace; a local run using a server connection completes such changes as a server-side request and commits or rolls back the document index, body, and tombstone within the same PostgreSQL transaction, avoiding concurrent writes or inconsistent data left by a single failure. The markdownPreview used for list and write summary responses only scan up to 16K characters at the beginning of the body to avoid additional copying or blocking saving of multi-MB documents. Reading the complete body still uses the get interface.
Kanban knowledge.listDocuments/getDocument/createDocument/editDocument/deleteDocument/searchDocuments/readDocumentLines reuses these document formats and storage semantics. Desktop project details show the same local data, while Server Web shows the same server data. A public Kanban Embed requires a login and a valid token. Named configurations remain isolated by user, while project KV and knowledge are shared by all authorized visitors and can be edited or deleted.
Agent Built-in Project MCP
The built-in Codex and OpenCode projects create temporary Streamable HTTP MCP endpoint Knowledge and KV two 127.0.0.1-only bindings during each agent turn and inject the agent via their respective SDK config. Small helper reuse OpenCode will also add a global memory endpoint. endpoint using a random path and a Bearer token, the turn closes when it completes, fails, cancels, or retries cleanup, eliminating the need to deploy MCP services separately.
| Project | Tool Management Name | MCP Tools | Data mapping |
|---|---|---|---|
| Codex | mcp__workflow-knowledge__knowledge_documents | knowledge_documents | Uses Knowledge helpers at the location selected by the declaration and runtime host. |
| Codex | mcp__workflow-kv__kv_store | kv_store | project and conversation use project/conversation KV scopes at that selected location. |
| OpenCode | workflow-knowledge_knowledge_documents | knowledge_documents | Access project markdown documents through the same knowledge base helper. |
| OpenCode | workflow-kv_kv_store | kv_store | Use the same project / conversation KV scope as Codex. |
| Little assistant | workflow-memory_global_memory | global_memory | Execute list/get/search/remember/forget in the small helper stable Knowledge namespace. |
The knowledge base tool supports list/get/create/edit/delete/search/read_lines; the KV tool only opens get/set that is already supported by the underlying KV, and the value must be JSON serializable. These tools are registered in tools corresponding to executor and are enabled by default and automatically approved. Desktop or server tool management can disable or change to manual approval on a case-by-case basis; manual approval is still handled by workflow.createToolApprovalGate() and the existing waiting-input recovery protocol. Codex uses the SDK approve mode to skip internal second-level approvals;OpenCode makes the workflow MCP tool callable, but the actual operation still goes through the same workflow approval gate.
The assistant's global_memory records only stable, reusable user preferences, changes, and project decisions. remember USES STABLE memoryId to upsert the assistant-memory-* document;forget to delete the corresponding document. The tool rejects common keys, tokens, passwords, and private key content, and should not save one-time progress or temporary errors. The namespace is shared across target projects within the scope of the native helper, but is not automatically synchronized to Workflow Server, other devices, or the general project Knowledge.
Conversation Document Mirroring
The conversation-knowledge example shows the mode of converting a session into a knowledge base document: the same conversation id corresponds to 1 markdown document. each round of messages is written to context.conversation, and then the complete transcript is synchronized to the knowledge base.
const state = await context.conversation.appendMessage({
role: "assistant",
content: `Saved to a knowledge-base document: ${input.message}`,
});
await workflow.editKnowledgeDocument(context.storage.local, {
id: documentId,
title,
markdown: renderTranscript(state.messages),
});
The document id can be derived from conversationId, so subsequent runs of the same conversation will update the same document.
Token usage statistics
Token usage is disabled by default. Once enabled on the executor, runtime writes cumulative totals only to the current project's KV and, when a conversation exists, that conversation's KV. Server derives the system total by aggregating project totals instead of maintaining a separate system KV. Runtime also writes this run's aggregate to the report and runtime events:
export const executor = workflow.defineExecutor<Input, OutputPayload>({
projectType: "workflow",
defaultEntrypoint: "main",
entrypoints: [
workflow.defineEntrypoint<Input, OutputPayload>({
id: "main",
title: "Full workflow",
workflow: assistantWorkflow,
tokenUsage: {
enabled: true,
display: true,
},
createInput({ args }) {
return { message: args.join(" ") };
},
}),
],
});
enabled controls statistics and KV writing;display controls whether the Desktop/server front end displays token badge. When display is omitted, it follows enabled by default. The total amount is displayed in the badge body. When hover or keyboard is focused, Input, Output, Reasoning, Cache read, Cache write, and Calls are displayed respectively.
The usage returned by the provider is automatically read from the provider parsed by workflow.getLLMProvider(). Custom nodes can also be manually reported:
await context.tokenUsage.report({
inputTokens: 120,
outputTokens: 80,
totalTokens: 200,
reasoningTokens: 12,
cachedInputTokens: 40,
cacheCreationTokens: 16,
nodeName: "answer",
providerName: "openai",
modelId: "gpt-5",
});
When tokenUsage.enabled is not turned on,context.tokenUsage.report() is a safe no-op.
Open Session
Session capability is enabled by the explicit item type of executor:
export const executor = workflow.defineExecutor<Input, OutputPayload>({
projectType: "conversation",
workflow: conversationWorkflow,
createInput({ args, conversationId }) {
return { message: args.join(" "), conversationId };
},
});
After declaration you can use:
const state = await context.conversation.getValue();
await context.conversation.appendMessage({ role: "user", content: input.message });
await context.conversation.setValue("topic", "weather");
await context.conversation.setTitle("Weather assistant");
setTitle(title) writes the header to the session state and triggers the header update event in Desktop/server embed. The header is cleared when the header is an empty string; when projectType: "workflow",setTitle is a safe no-op.
Default input box
If workflow declares conversation.defaultInput,Desktop, server embed, and public embed will render the shared default input box first. The default input box is serialized to the 3 group CLI parameter:
The unsent text, attachments, and additional parameters are synchronized as the same draft; when you adjust the Advanced or shortcut parameters, the entered text and attachments remain unchanged.
- Text:
--message - Image Attachment:
--images - Common file attachments:
--files
workflow still needs to read these parameters explicitly in createInput({ args }); workflow.parseConversationDefaultInputArgs(args) is recommended. The helper takes the last --message and merges all --images / --files file references.
Both the codex and opencode examples have picture attachments enabled. Codex is converted to local_image and OpenCode is converted to data URL file part during SDK turn; both only persist the picture reference with the user message so that the Desktop and server embed playback attachments.
Input Queue
If workflow declares conversation.inputQueue: { enabled: true },Desktop and server embed will continue to accept new inputs while the current session is running and put them into the host persistence queue. After the current run completes, the queue items continue in order; both codex and opencode workflow have this mode enabled.
conversation_id
External API, server run, embed run, and Desktop local sessions can all pass in or generate a conversation id.
- Desktop generates and reuses a conversation id for each local chat session.
- External API uses request body
conversation_id. - Embed will sign the conversation id of the request to prevent cross-token/session reuse.
- webhook or third-party payload can return a stable
conversationIdincreateInput. - Server Log Replay will use
displayInputordisplay_inputin the final output as a conversation user bubble to display content.
Session State Structure
interface WorkflowConversationState {
conversationId: string;
workflowName: string;
title?: string;
values: Record<string, unknown>;
messages: WorkflowConversationMessage[];
}
The session message should hold the displayable content and necessary references. For large files, it is recommended to save the file reference and not write raw binary or base64 long text into the session state.