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,
    })
  )
);

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.

Released under the MIT License.