Skip to main content
Conversations & Sessions

External Session Storage

By default, qodercli keeps session history on the machine where it runs. In a multi-instance, container, or serverless deployment, the next request may reach a different machine that cannot read the earlier session. External session storage keeps a mirror of each session in storage that your application controls. The mirror is an additional copy: qodercli still writes the session locally, and any host can continue a session later by using its session ID. Use external storage when:
  • requests can move between service instances
  • local disks are temporary or unreliable
  • your application must control access, encryption, backups, or retention
For an application that always runs on one machine, local session storage is usually enough.

How it works

first request
  query()
    -> qodercli writes the session locally
    -> the SDK mirrors new session entries to your store

later request, on any host
  query (with resume=session_id)
    -> the SDK loads session history from your store
    -> qodercli continues the session
Two properties follow from this design:
  • Writes are best-effort. The SDK mirrors entries in the background. A failed write is reported but never stops the running conversation; see Operations.
  • Entries are opaque. Your store persists and returns SDK-defined transcript entries as-is. Your application does not need to understand qodercli's local file format.

Quick start

InMemorySessionStore keeps data in the current process only. Use it to validate the wiring — that queries save to and resume from a store — before you connect a shared backend. It cannot demonstrate cross-host resume, because the data is gone when the process exits; for that, implement a real store. See Implementing a store.
import {
  InMemorySessionStore,
  qodercliAuth,
  query,
} from '@qoder-ai/qoder-agent-sdk';

const store = new InMemorySessionStore();
const options = {
  auth: qodercliAuth(),
  cwd: '/path/to/project',
  sessionStore: store,
};

// First query: run and capture the session ID.
let sessionId: string | undefined;
for await (const message of query({
  prompt: 'Remember the number 42.',
  options,
})) {
  if (message.type === 'result') sessionId = message.session_id;
}
if (!sessionId) throw new Error('The first query did not return a session ID.');

// Later query: resume from the store and the model recalls prior context.
for await (const message of query({
  prompt: 'What number did I ask you to remember?',
  options: { ...options, resume: sessionId },
})) {
  if (message.type === 'result') console.log(message.result); // -> "42"
}
Every host must use the same cwd (workingDirectory in Java), so the SDK identifies requests as belonging to the same project. In Java, mirroring writes are asynchronous. When a session closes, the SDK waits up to closeDrainTimeoutMs (5 seconds by default) for the remaining entries. When you resume right away in the same process, as in the example above, wait for the previous query's closeAsync() to complete first.

Using a store in your application

For every query that should mirror or restore a session, pass the store in options (sessionStore in TypeScript, session_store in Python), then choose how to identify the session:
  • Known session ID — pass resume.
  • The project's most recent session — pass continue: true (TypeScript) / continue_conversation=True (Python) / continueSession(true) (Java). This requires the store to implement the session-listing method.
In Java, QoderClient.Builder.sessionStore(store) sets the client's default store, which queries, sessions, and client.sessions() all use. A single session can override it with SessionOptions.Builder.sessionStorage(...): SessionStorage.external(otherStore) switches to another store, and SessionStorage.cliLocal() uses only the CLI's local history. Java picks the most recent session by the latest timestamp of the root transcript entries in the store. If entries have no timestamp, the latest timestamps tie, or no session is available, startup fails with InvalidOptionsException; pass an explicit session ID instead.

Managing stored sessions

Session management functions operate on the store instead of local files. In TypeScript, pass the store via the sessionStore option; in Python, each function takes the store as its first argument; in Java, client.sessions(options) returns a SessionRepository bound to the store and project directory. Pass the same options you created the sessions with; otherwise sessions saved under their workingDirectory are not found. client.sessions() uses the client's projectDirectory, which suits setups that configure the project directory once on QoderClient.Builder:
import {
  listSessions,
  getSessionInfo,
  getSessionMessages,
  listSubagents,
  getSubagentMessages,
  renameSession,
  tagSession,
  forkSession,
  deleteSession,
} from '@qoder-ai/qoder-agent-sdk';

const project = { dir: '/path/to/project', sessionStore: store };
const sessions = await listSessions(project);
const messages = await getSessionMessages(sessionId, project);
await renameSession(sessionId, 'Investigation #1', project);
await deleteSession(sessionId, project);
Listing sessions requires the store to implement the session-listing method; listing subagents requires listSubkeys / list_subkeys; deleting sessions requires delete. If listSubkeys / list_subkeys is implemented, the subagent-message getters use it too. Java's list() requires the store to implement ListableSessionStore, and delete() requires SessionDeletionStore; delete() removes the session's root transcript and all subagent transcripts. Without a configured store or the required interface, these methods fail with UnsupportedCliCapabilityException. The built-in InMemorySessionStore implements both interfaces. Java provides no functions to rename, tag, or import sessions. To copy an existing local session into the store—for example when migrating a machine that previously ran without external storage—use the import function; note the session ID comes first and the store second:
import { importSessionToStore } from '@qoder-ai/qoder-agent-sdk';

await importSessionToStore(sessionId, store, { dir: '/path/to/project' });

Implementing a store

The SDK ships no production-ready storage implementation; applications implement the SessionStore interface (TypeScript) / protocol (Python) / interface (Java) against the shared storage of their choice. Runnable reference implementations for Redis and PostgreSQL show the mechanics: see the TypeScript examples or Python examples. They are starting points, not production-ready implementations.
type SessionKey = {
  projectKey: string; // derived from cwd; identifies the project
  sessionId: string; // the session UUID
  subpath?: string; // set for subagent transcripts, e.g. "subagents/agent-<id>"
};

type SessionStoreEntry = {
  type: string;
  uuid?: string;
  timestamp?: string;
  [key: string]: unknown; // opaque transcript line — store and return unchanged
};

interface SessionStore {
  // Required
  append(key: SessionKey, entries: SessionStoreEntry[]): Promise<void>;
  load(key: SessionKey): Promise<SessionStoreEntry[] | null>;
  // Optional — implement only the features you need
  listSessions?(
    projectKey: string,
  ): Promise<Array<{ sessionId: string; mtime: number }>>;
  delete?(key: SessionKey): Promise<void>;
  listSubkeys?(key: Omit<SessionKey, 'subpath'>): Promise<string[]>;
}
A SessionKey identifies one transcript. The main session has no subpath; each subagent transcript reuses the same project key and session ID with a different subpath. Treat keys and entries as opaque—store and return them verbatim, and do not parse message contents. The two required methods provide save and resume. Each optional method unlocks a feature:
MethodFeature enabled
append, loadMirror a session and resume it by ID — required
listSessions / list_sessionsResume the most recent session; list stored sessions
deleteDelete a session from the store
listSubkeys / list_subkeysFully restore and inspect subagent transcripts

Implementation checklist

  • Preserve append order per key, and return the complete history from load. Replay depends on order.
  • Isolate keys. Never return one key's entries under another.
  • Make append idempotent. The SDK may retry a failed write with the same entries, so a retry must not duplicate history.
  • When listing sessions, mtime returns Unix-millisecond timestamps, and only main sessions—those without a subpath—are returned.
  • Cascade deletes. Deleting a main session must also delete its subagent transcripts.
  • listSubkeys / list_subkeys returns relative identifiers only—never absolute paths, and never paths containing . or ... These become storage keys, and traversal segments would let transcripts escape their namespace.
  • Serialize concurrent writers for the same session in the storage layer if more than one process can write it.
Use the SDK-provided conformance tests to check these generic behaviors (TypeScript ships SessionStore conformance tests in the SDK repo; Python provides run_session_store_conformance in qoder_agent_sdk.testing), and add concurrency and retry tests for your specific backend. Connection management, permissions, encryption, backups, migration, and retention remain the application's responsibility.

Java implementation differences

The Java interfaces append in batches and differ from the checklist above:
  • Idempotency and CAS. append is idempotent per AppendBatch.operationId(); a replay returns the original operation's version and count. When expectedVersion() is not null, append performs a CAS and fails with SessionStoreConflictException on a version mismatch.
  • Listing sessions. listSessions returns every key under the project, including subagent subpath keys, in no guaranteed order. The most recent session is selected by the timestamp in the entries; mtime is not needed.
  • Deletion. deleteSessionTree deletes the root transcript and all subpaths in one transaction and permanently marks the session as deleted; later writes to that session are rejected with SessionStoreDeletedException. deleteSession deletes a single key only.
  • Asynchrony. The two required methods may complete synchronously, but they must report failures through an exceptionally completed stage instead of throwing directly, and must not do blocking I/O on the calling thread.
For key and value types, see Session Storage; for the full contract, see Implementation contract.

Operations

Failure handling. External write failures never interrupt the ongoing conversation. After the final retry fails, the SDK emits a mirror-error message (system/mirror_error in TypeScript, SDKMirrorErrorMessage in Python, MirrorErrorMessage in Java). Monitor it if mirror integrity matters—the conversation may still complete even when a write fails. In Java, after a write to a key fails or times out, mirroring stops for that key; other keys are unaffected. The error text of MirrorErrorMessage is in the error field of rawJson(). Its format is not stable, so do not match on the string. Tuning.
  • External reads wait up to 60 seconds by default. Tune with loadTimeoutMs / load_timeout_ms.
  • The flush strategy (sessionStoreFlush / session_store_flush) defaults to batched. Setting eager mirrors entries sooner at the cost of more storage requests.
  • Java has no loadTimeoutMs or flush strategy option. Set the mirror's pending batch, byte, and wait limits with sessionMirrorOptions(...); see SessionMirrorOptions.
Constraints.
  • If an explicitly resumed session is missing from the store, the SDK can still fall back to a local session with the same ID. Java does not fall back: if the explicitly specified record is missing or empty in the store, startup fails with InvalidOptionsException.
  • The store cannot be combined with file checkpointing, custom transports, or the experimental Cloud Agent runtime (in TypeScript it also cannot be combined with persistSession: false). In TypeScript the store is supported on the built-in Process and Worker transports; Python requires the built-in subprocess transport.
  • A store holds session history only. It does not hold authentication state, application configuration, file checkpoints, or retention policy.
  • In Java, setting an effective store together with fileCheckpointing(true) fails with InvalidOptionsException.
  • In Java, forking a session on an external store requires the CLI to support the corresponding capability (see capability requirements). The SDK completes initialization and sends input only after the fork's initial records are written to the store; if the capability is missing or the write fails, startup fails.
Deployment requirements (Java). When Java restores an external session, it creates a private temporary directory on the local machine and shares the original CLI configuration through directory symbolic links. Windows allows only processes holding SeCreateSymbolicLinkPrivilege to create symbolic links: standard users do not hold it by default, and administrator accounts hold it only when running elevated. Without this privilege, resuming, continuing, and forking external sessions all fail with CLI_START_FAILED before the CLI starts, and the error message explains why.