Upgrade: request keys (D-2 MR 1)
This release adds a request key: an idempotency key a client sends with a request, so a retried request never runs twice. The entry's run-start commit records the key on its run, and the store keeps it unique per session. A repeat of the key attaches to the original run, replays it, or is refused typed. It never starts a second run or appends the input twice.
It is a major release of @helix-agents/ai-sdk (new typed answers and the header on the chat route) and @helix-agents/runtime-cloudflare (the DO's entry wire gains an attached answer), and a minor release of @helix-agents/core (a 0.x package), @helix-agents/runtime-js, @helix-agents/runtime-temporal, @helix-agents/runtime-dbos, @helix-agents/agent-server and every store package (@helix-agents/store-memory, @helix-agents/store-redis, @helix-agents/store-postgres, @helix-agents/store-cloudflare).
Design: 2026-10-13-d2-request-key-design.md. API reference: Request keys.
Deploying
- Run the store migrations (below) before the new runtimes. A runtime that records keys writes the new columns in its run-start commit.
- No workflow input, workflow argument or instance param changed, so no Temporal or DBOS drain is needed. A keyless request behaves exactly as before.
1. The key
| Where | How |
|---|---|
| HTTP | the X-Helix-Request-Key header (core REQUEST_KEY_HEADER) |
| executor calls | ExecuteOptions.requestKey, every ResumeOptions variant's requestKey, RetryOptions.requestKey |
handleChatStream | HandleChatStreamParams.requestKey (wins over the header) |
| remote protocol (servers) | RemoteStartRequest.requestKey, RemoteResumeRequest.requestKey; RemoteStartResponse.status: 'attached' |
A key is 1–128 characters of [A-Za-z0-9._:-] (core parseRequestKey). Anything else is a non-retryable validation_error (HTTP 400) before any lookup, and the raw value is never echoed. Send one new key per request, and the same key when you retry that request.
2. What a repeated key gets
The key's run is looked up before any concurrency policy, and core planKeyedEntry decides:
| The key's run | Answer |
|---|---|
| none | a new entry; its run-start commit records the key and a digest of the entry's input |
live (running or suspended_*) | attach: the original run (handle.attached: true; HTTP 200 status: 'attached'); nothing executes |
| finished, and still the session's current run | replay: the same attached answer; result() reports how the run ended |
| finished, and a later run of the session exists | 409 state_request_already_completed (RequestAlreadyCompletedError, body runId); nothing written |
| any status, but the request's input is different | 409 state_request_key_conflict (RequestKeyConflictError, body runId); nothing written |
Both new codes are non-retryable state errors. After state_request_already_completed, re-anchor through the snapshot, which holds the turn. After state_request_key_conflict, send the new request with a new key. If your code switches over error codes or HTTP answers, add both.
3. Store implementers
SessionStateStore gains a required method, and the run-start commit gains a rule.
interface SessionStateStore {
getRunByRequestKey(sessionId: string, requestKey: string): Promise<RunMetadata | null>;
// ...
}
interface StartRun {
// ...
requestKey?: string;
requestDigest?: string;
}
interface RunMetadata {
// ...
requestKey?: string;
requestDigest?: string;
}- Record the key. A run-start commit with
startRun.requestKeywritesrequestKeyandrequestDigeston the new run, sogetRun,getCurrentRun,listRunsandgetRunByRequestKeyreturn them. A keyless run has neither. - Keep it unique per session, inside the commit. Read the run of this session that holds the key in the same atomic step as the rest of the run-start commit, and pass it to
applyRunStartRules({ requestKeyHolder }). Another run holding it makes the rules throwRunStartRejectedError('request_key_taken', { existingRunId }); the commit writes nothing. The rule sits after the same-runIdreplay check and before the CAS (Run start).requestKeyandrequestDigestare part ofstartDigest, so a matched replay stays a no-op. getRunByRequestKeyis an indexed read that never resolves a key to another session's run.deleteSessionfrees the session's keys.- Run the contract suite.
runStartOperationTests(andstateStoreContractTests) now include therequest-key.*cases: the key recorded and found,request_key_takennaming the holder, a same-runIdreplay, per-session keys, a refused start that leaves the key free, a race of two commits with one key, anddeleteSessionfreeing the keys.
Per-store storage and migrations
The migration numbers are provisional and are fixed at the final rebase onto main.
| Store | Storage | Migration |
|---|---|---|
| Memory | a per-session key index | none |
| Redis | a hash session:request-keys:{sessionId} (key → run id), checked in the run-start Lua | none |
| Postgres | __agents_runs.request_key / request_digest + a unique partial index (built concurrently) | V18 |
| D1 | __agents_runs.request_key / request_digest + a unique partial index | V22 |
| DO-SQLite | runs.request_key / request_digest + a unique partial index | 16 |
Each index is on (session_id, request_key) WHERE request_key IS NOT NULL. On Postgres the holder is read under the session's row lock, so the index is a backstop; if it ever fires, the store answers request_key_taken, never the raw unique-violation error.
4. Executor implementers and wrappers
AgentExecutor gains an optional flag:
interface AgentExecutor {
readonly supportsRequestKey?: boolean; // absent means false
// ...
}- An executor that records keys declares
supportsRequestKey: true. It must look the key up before any concurrency policy, decide withplanKeyedEntry, return a handle withattached: trueon the original run forattach/replay, throw the two typed errors, pass the key and digest on its run-start commit (StartRun.requestKey/requestDigest, digests fromexecuteRequestDigest/resumeRequestDigest/retryRequestDigest), and re-plan once afterrequest_key_taken. - An executor that does not should declare
supportsRequestKey: falseand ignore a key loudly:ignoredRequestKeyWarner(runtime)logsrequest_key.ignoredonce per executor instance. It must never claim a dedupe it does not do. - A wrapper around an executor (a tracing or routing proxy) should forward
supportsRequestKeyand the handle'sattachedflag.handleChatStreamforwards a key only when the flag istrue, so a wrapper that drops it turns every keyed send keyless.
| Runtime | supportsRequestKey |
|---|---|
runtime-js (JSAgentExecutor) | true: execute(), resume() and retry() |
CF Durable Object (DOFrontendExecutor) | true: the native routes and the /subagent routes |
CF Workflows (CloudflareAgentExecutor) | false: the key is ignored with a warning |
| Temporal | false: the key is ignored with a warning |
| DBOS | false: the key is ignored with a warning |
5. Hosts and HTTP clients
handleChatStreamreadsparams.requestKey, else the header, on a request that carries messages (a reconnect ignores it). A keyed send whose key the session's current run holds, with the same input, attaches to that run (its stream, or its terminal replay) without calling the executor, so a retried send gets its original reply instead of the 409state_already_running. Other input under that key is 409state_request_key_conflict. A tool-result request stays keyless in this release.useHelixChatsends a fresh key persendMessage(a caller-suppliedX-Helix-Request-Keyheader is kept).retry()re-sends with the same key (RefusedSend.keyafter a refusal); the returnedregenerate()mints a new one.createHelixChatTransportpasses per-call headers and mints one key per user message.- agent-server reads the header on
/start,/resumeand/chat(400 on a malformed key). A keyed/startor/resumewhose key a run of the session holds skips the session-exists / completed refusals and answersstatus: 'attached'.ChatStreamHandlerparams gainrequestKey. - The CF Durable Object answers a keyed duplicate on
/start,/resume,/retryand/subagent/:type/*from its key before the concurrency check:200 { sessionId, streamId, runId, status: 'attached', startSequence }, or the two 409s (ALREADY_COMPLETED/INVALID_REQUESTwith the typederrorCodeon/subagent). A keyed request waits for the DO's producer claim (bounded byconcurrentEntryTimeoutMs) instead of answering busy at once. - A stock
useChator a raw HTTP client sends no key unless you set the header. Without one, nothing changes.
Breaking: createCloudflareChatHandler().handleChat requires request
The key reaches handleChatStream only through the incoming request (or requestKey). A Durable Object route that called handleChat({ sessionId, messages, signal }) without request dropped the key without an error, so every retried send ran again. CloudflareHandleChatParams.request is now required on every call, sends included, and requestKey?: string overrides the header. Pass the route's request:
// Before
return chat.handleChat({ sessionId, messages, signal: req.signal });
// After: the request carries the key, the resume headers and the abort signal.
return chat.handleChat({ sessionId, messages, request: req });Only the request's headers and signal are read, so a request whose body your route already consumed is fine. handleChatStream's own request stays optional (a non-HTTP caller passes requestKey instead).
Express: createWebResponseExpressMiddleware now hands its handler { signal, request }. request is a body-less web Request with the incoming URL, method and headers, whose signal is signal. Pass it as request. To call handleChatStream yourself, build one with the new toWebRequest(req, signal?) from @helix-agents/ai-sdk/adapters/express:
app.post(
'/api/chat/:sessionId',
createWebResponseExpressMiddleware(async (req, { request }) =>
handleChatStream(deps, {
sessionId: req.params.sessionId,
messages: req.body.messages,
request,
})
)
);Keyed chat sends follow the DO's concurrentEntry policy (D-2 MR 1.5). DOFrontendExecutor marks only keyless sends with the internal x-helix-reject-concurrent-entry header. A keyed send that is not a duplicate follows the DO's policy like a native entry: on a concurrentEntry: 'interrupt_previous' DO it stops the executing run and starts its turn, and its response shows only its own run (from the run's start, pinned to its runId). On the default 'reject' policy it is still refused 409 state_already_running. A keyless send is refused 409 under either policy, as before. If you relied on interrupt_previous NOT applying to chat sends, keep the DO on 'reject'.
A send's response is its own run's, and MinimalStateStore.getCurrentRun is required (ai-sdk).handleChatStream takes a send's run from the handle (MinimalExecutionHandle.runId + startSequence, as the CF DO's handle carries) or from getCurrentRun() after execute(). A custom MinimalStateStore must now implement getCurrentRun; every SessionStateStore and the DO's state client already do. When the send's run cannot be identified, the response is the typed transport_error with the input committed (re-read the snapshot with useHelixChat().retry(); do not send again), never a reply streamed from the stream head. The remote-agent transports (HttpRemoteAgentTransport, DOStubTransport) do not send keys yet (D-2 MR 2).
6. Added APIs
From @helix-agents/core: REQUEST_KEY_HEADER, REQUEST_KEY_PATTERN, parseRequestKey, computeRequestDigest, executeRequestDigest, resumeRequestDigest, retryRequestDigest, planKeyedEntry, ignoredRequestKeyWarner, RequestKeyConflictError, RequestAlreadyCompletedError, and the types RequestDigestInput and KeyedEntryPlan. RunStartRejectedError gains the cause request_key_taken and a details field (existingRunId); classifyEntryFailure classifies it transient. TypedErrorBody and PeerErrorBodySchema gain runId, and SnapshotViewRun gains requestKey / requestDigest.
From @helix-agents/ai-sdk/adapters/express: toWebRequest. CloudflareHandleChatParams gains requestKey, and its request is now required.