Skip to content

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 ​

WhereHow
HTTPthe X-Helix-Request-Key header (core REQUEST_KEY_HEADER)
executor callsExecuteOptions.requestKey, every ResumeOptions variant's requestKey, RetryOptions.requestKey
handleChatStreamHandleChatStreamParams.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 runAnswer
nonea 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 runreplay: the same attached answer; result() reports how the run ended
finished, and a later run of the session exists409 state_request_already_completed (RequestAlreadyCompletedError, body runId); nothing written
any status, but the request's input is different409 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.

typescript
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.requestKey writes requestKey and requestDigest on the new run, so getRun, getCurrentRun, listRuns and getRunByRequestKey return 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 throw RunStartRejectedError('request_key_taken', { existingRunId }); the commit writes nothing. The rule sits after the same-runId replay check and before the CAS (Run start). requestKey and requestDigest are part of startDigest, so a matched replay stays a no-op.
  • getRunByRequestKey is an indexed read that never resolves a key to another session's run. deleteSession frees the session's keys.
  • Run the contract suite. runStartOperationTests (and stateStoreContractTests) now include the request-key.* cases: the key recorded and found, request_key_taken naming the holder, a same-runId replay, per-session keys, a refused start that leaves the key free, a race of two commits with one key, and deleteSession freeing the keys.

Per-store storage and migrations ​

The migration numbers are provisional and are fixed at the final rebase onto main.

StoreStorageMigration
Memorya per-session key indexnone
Redisa hash session:request-keys:{sessionId} (key → run id), checked in the run-start Luanone
Postgres__agents_runs.request_key / request_digest + a unique partial index (built concurrently)V18
D1__agents_runs.request_key / request_digest + a unique partial indexV22
DO-SQLiteruns.request_key / request_digest + a unique partial index16

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:

typescript
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 with planKeyedEntry, return a handle with attached: true on the original run for attach / replay, throw the two typed errors, pass the key and digest on its run-start commit (StartRun.requestKey / requestDigest, digests from executeRequestDigest / resumeRequestDigest / retryRequestDigest), and re-plan once after request_key_taken.
  • An executor that does not should declare supportsRequestKey: false and ignore a key loudly: ignoredRequestKeyWarner(runtime) logs request_key.ignored once 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 supportsRequestKey and the handle's attached flag. handleChatStream forwards a key only when the flag is true, so a wrapper that drops it turns every keyed send keyless.
RuntimesupportsRequestKey
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
Temporalfalse: the key is ignored with a warning
DBOSfalse: the key is ignored with a warning

5. Hosts and HTTP clients ​

  • handleChatStream reads params.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 409 state_already_running. Other input under that key is 409 state_request_key_conflict. A tool-result request stays keyless in this release.
  • useHelixChat sends a fresh key per sendMessage (a caller-supplied X-Helix-Request-Key header is kept). retry() re-sends with the same key (RefusedSend.key after a refusal); the returned regenerate() mints a new one. createHelixChatTransport passes per-call headers and mints one key per user message.
  • agent-server reads the header on /start, /resume and /chat (400 on a malformed key). A keyed /start or /resume whose key a run of the session holds skips the session-exists / completed refusals and answers status: 'attached'. ChatStreamHandler params gain requestKey.
  • The CF Durable Object answers a keyed duplicate on /start, /resume, /retry and /subagent/:type/* from its key before the concurrency check: 200 { sessionId, streamId, runId, status: 'attached', startSequence }, or the two 409s (ALREADY_COMPLETED / INVALID_REQUEST with the typed errorCode on /subagent). A keyed request waits for the DO's producer claim (bounded by concurrentEntryTimeoutMs) instead of answering busy at once.
  • A stock useChat or 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:

typescript
// 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:

typescript
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.

Released under the MIT License.