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,
})
)
);DOFrontendExecutor still marks keyed chat sends with the internal x-helix-reject-concurrent-entry header in this release, so a keyed send that is not a duplicate is still refused 409 state_already_running while a run executes, even under concurrentEntry: 'interrupt_previous'. D-2 MR 1.5 limits that header to keyless sends. 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.