Project introduction

Durable, asynchronous chat with replaceable workers.

Stateless Chat is a TypeScript reference implementation for a trusted operator who needs an OpenAI-compatible assistant with persistent conversations, live token delivery, tool use, and recoverable background processing. It separates request handling from model execution so an HTTP request can return immediately and any worker can continue the turn.

Problem

Long LLM calls do not belong in a synchronous request lifecycle. They must survive page reloads, missed notifications, process restarts, and multiple workers without producing duplicate replies.

Scope

The repository includes a React client, Express gateway, PostgreSQL/Redis infrastructure, an LLM worker, built-in host tools, durable compaction, and operator recovery scripts.

Audience

Engineers reviewing, running, debugging, or extending the system. It is a demonstration and trusted single-operator deployment, not a hardened multi-tenant service.

Mental model

A user message is both transcript content and a durable job row. The gateway stores it in PostgreSQL, uses Redis only to wake workers, and returns a stream URL. One worker acquires a renewable lease, rebuilds context from PostgreSQL, runs the model/tool loop, publishes transient progress through Redis, and commits the final transcript back to PostgreSQL. The browser can reconnect because durable state—not a worker process—is authoritative.

Runtime
Node.js 20+, TypeScript
Durable store
PostgreSQL 16
Signal bus
Redis 7 Pub/Sub
Client
React 19 + Vite
Transport
REST + named SSE events
Model API
OpenAI-compatible chat completions

2 · System map

Components and boundaries

PostgreSQL owns durable truth; Redis carries transient signals. Boxes show the deployment parts, while connecting labels show the functions that advance a turn.

Architecture at a glance

Boxes are system parts. Connector labels name the functions or channels crossing each boundary.

flowchart TB
    USER(["User"])
    WEB["Web client
App.tsx"] GATEWAY["Gateway
gateway/index.ts"] POSTGRES[("PostgreSQL
messages + leases")] REDIS[("Redis
Pub/Sub channels")] WORKER["Worker
worker/index.ts"] MODEL["Model API
OPENAI_MODEL"] TOOLS["Tool registry
DEFAULT_TOOLS"] HOST["Trusted host
/home/ubuntu"] USER -->|"prompt"| WEB WEB <-->|"sendMessage / streamReply"| GATEWAY GATEWAY <-->|"insertUserMessage / getHistory"| POSTGRES GATEWAY <-->|"publish / subscribe"| REDIS POSTGRES <-->|"claimPendingMessages / markMessageDone"| WORKER REDIS <-->|"wake-up / streamChannel"| WORKER WORKER <-->|"runToolLoop"| MODEL WORKER -->|"executeToolCall"| TOOLS TOOLS -->|"file / shell effect"| HOST
Principal functions by runtime part
PartMain functionsMechanismState boundary
Web stateApp, handleSend, conversation reducerKeys messages, active streams, errors, and submission guards by conversation so background turns remain independent.Browser memory; client id and selection in localStorage.
Web transportjsonFetch, sendMessage, getHistory, streamReplyConverts UI operations to authenticated REST; maps named SSE events into reducer callbacks and exposes a close function.No authoritative transcript state.
Gateway auth and routesmain, isAuthenticated, passwordMatches, route handlersValidates the shared-password cookie and request shape; persists user input before publishing a wake-up.Stateless except process-local login throttling.
Gateway SSE relaysend, deliver, cleanupSubscribes before checking PostgreSQL, buffers the race window, replays durable progress, then relays live named events.Connection-local buffer only; reconnects consult PostgreSQL.
PostgreSQL adapterinsertUserMessage, claimPendingMessages, renewLease, stillOwnsLeaseUses transactions, row locks, advisory locks, unique indexes, and leases to serialize work and make ownership explicit.Authoritative conversations, transcript, jobs, checkpoints, compactions, and file locks.
Redis adaptercreateRedisClient, createRedisSubscriber, streamChannelPublishes one global wake-up channel and message-specific progress channels.Transient acceleration only; events are not replayed.
Worker schedulerclaimAndProcess, processMessage, reconcileTurnStatePolls as a fallback, claims one turn, renews ownership, reconciles incomplete work, and settles done/failed state.Replaceable process; durable progress stays in PostgreSQL.
Model contextbuildCompactedMessages, shouldCompact, summarizeBuilds a bounded prompt from raw rows and the newest additive compaction checkpoint.Raw history remains immutable; summaries are prompt metadata.
Model and toolsrunToolLoop, executeToolCall, DEFAULT_TOOLSStreams completions, assembles tool deltas, validates dispatch, and checkpoints each call before and after execution.Provider and host are external-effect boundaries.

Repository responsibility map

packages/shared

Cross-process types, schema/queries, environment parsing, Redis names, auth signing, tool manifests, and context estimates.

packages/gateway

HTTP authentication and validation, conversation endpoints, client-facing history projection, and race-safe SSE relay.

packages/worker

Polling/claims, renewable leases, crash reconciliation, tool loop, compaction, lifecycle events, and built-in tools.

web

Authentication screen, local client identity, per-conversation reducer, background streams, slash-tool UI, and transcript rendering.

scripts

Infrastructure readiness, live SSE checks, and explicit operator recovery for failed or ambiguous turns.

3 · Primary workflow

One user message from request to durable reply

The synchronous request ends at step 4. Generation, tools, and recovery execute as a background database workflow.

sequenceDiagram
    autonumber
    participant C as Browser / curl
    participant G as Gateway
    participant P as PostgreSQL
    participant R as Redis
    participant W as Worker
    participant M as LLM / tools

    C->>G: POST /conversations/:id/messages
    G->>P: insertUserMessage(content, status=pending)
    P-->>G: user message id
    G->>R: PUBLISH chat:new_message {ids}
    G-->>C: 200 {messageId, streamUrl}
    C->>G: GET streamUrl (EventSource)
    G->>R: SUBSCRIBE chat:stream:{messageId}
    G->>P: getReply + persisted tool progress
    R-->>W: wake-up (or 1s poll finds row)
    W->>P: claimPendingMessages(..., limit=1)
    P-->>W: processing row + lease
    loop each LLM/tool iteration
      W->>M: streaming chat completion
      M-->>W: text deltas and/or tool calls
      W->>R: token / tool_* / lifecycle events
      W->>P: tool request before execution
      W->>M: execute selected tools
      W->>P: tool result immediately after execution
    end
    W->>P: insertAssistantMessage + markMessageDone
    W->>R: done(full content + usage)
    R-->>G: per-message events
    G-->>C: named SSE events, then close
  1. Accept and authenticate

    The React client calls /api; Vite rewrites that prefix to gateway port 3000. Except for login, logout, auth status, and health, the gateway requires a valid chat_auth cookie.

    Entry: web/src/lib/api.ts → packages/gateway/src/index.ts

  2. Persist before signaling

    insertUserMessage creates a role=user, status=pending row. Only after the insert resolves does the gateway publish ids on chat:new_message.

    Durability: packages/shared/src/db.ts

  3. Return immediately

    The gateway returns conversationId, messageId, and a message-specific stream URL. Model latency is outside the POST lifecycle.

  4. Attach without a completion race

    The SSE route subscribes to Redis before querying PostgreSQL. It buffers concurrent events, replays durable tool progress, returns a saved reply if one exists, or joins the live stream. A 15-second comment heartbeat keeps idle connections open.

  5. Claim exactly one turn

    Every worker reacts to Redis and polls every second. PostgreSQL selects one candidate with FOR UPDATE SKIP LOCKED, a worker lease, and a transaction advisory lock for conversation ordering.

  6. Reconcile a prior attempt

    Before contacting the model, reconcileTurnState checks for a saved final reply or an incomplete tool request. Read-only calls may be retried; ambiguous mutating calls require an operator.

  7. Run model and tools

    runToolLoop rebuilds safe history, applies compaction when needed, streams model deltas, persists each tool request before execution and each result immediately after, and continues until text completes.

  8. Settle and publish

    After a final ownership check, the worker inserts one final assistant row, best-effort generates a title, marks the user job done, and publishes the authoritative content and usage in done.

  9. Update every conversation independently

    The reducer keys history, stream handles, submission guards, and errors by conversation id. A background conversation continues streaming while another is selected.

HTTP surface
Method and pathAuthenticationPurpose and response
POST /loginPublic; rate-limited per process/IPValidate shared password; set signed cookie; 204.
POST /logoutPublicExpire cookie; 204.
GET /auth/statusPublic200 authenticated or 401.
GET /healthPublicProcess liveness only: {"ok":true}.
POST /conversationsCookieCreate by clientId; return id.
GET /conversations?clientId=…CookieRecent-first summaries and outstanding-turn metadata.
DELETE /conversations/:idCookie204; 404; or 409 while a live processing lease exists.
POST /conversations/:id/messagesCookiePersist pending user row; publish wake-up; return ids/stream URL. :id may be new.
GET /conversations/:id/messagesCookieGrouped full history plus compaction notes.
GET /conversations/:id/toolsCookieEnabled tool manifests.
GET /conversations/:id/messages/:messageId/toolsCookieFull tool arguments/results for one user turn.
GET /conversations/:id/messages/:messageId/streamCookieSSE before, during, or after processing.

4 · State and data model

Durable transcript, transient delivery, leased work

The same messages table holds conversation content and the user rows that workers claim as jobs.

User-row state machine

stateDiagram-v2
    [*] --> pending: insertUserMessage
    pending --> processing: claim + lease + attempt_count++
    processing --> processing: renewLease heartbeat
    processing --> done: final reply committed
    processing --> failed: execution error while owned
    processing --> processing: expired lease reclaimed
    processing --> failed: expired lease at max attempts
    failed --> pending: operator requeue
    pending --> [*]: conversation deleted before claim
    done --> [*]

Entity relationships

erDiagram
    CONVERSATIONS ||--o{ MESSAGES : contains
    CONVERSATIONS ||--o{ COMPACTIONS : summarizes
    MESSAGES o|--o{ MESSAGES : reply_to
    CONVERSATIONS {
      uuid id PK
      text client_id
      text title
      timestamptz created_at
    }
    MESSAGES {
      uuid id PK
      uuid conversation_id FK
      text role
      text content
      text status
      uuid reply_to_message_id FK
      jsonb tool_calls
      int iteration
      text worker_id
      timestamptz lease_expires_at
    }
    COMPACTIONS {
      text id PK
      uuid conversation_id FK
      text summary
      text first_kept_message_id
      text triggered_by_message_id UK
    }
    FILE_LOCKS {
      text lock_key PK
      text owner_token
      timestamptz expires_at
    }
State ownership and lifetime
StateLocationLifetimeRecovery behavior
Conversation and raw transcriptPostgreSQL conversations, messagesDurableReloaded after process/browser restart; compaction never edits or deletes raw rows.
Job ownershipUser message columns: status, worker, lease, attempts, errorDurable renewable leaseExpired work is reclaimable; exhausted work becomes failed.
Tool progressAssistant request rows + tool result rowsDurable per iteration/callReconciliation resumes at the next safe checkpoint.
Context summarycompactionsDurable additive metadataNewest checkpoint overlays old prompt context only.
Wake-ups and tokensRedis Pub/SubTransientPolling recovers missed jobs; PostgreSQL returns completed replies.
Active UI streamBrowser memory, per conversationTransientConversation list/history finds outstanding jobs and reattaches.
Client identity and selectionBrowser localStorageDevice-localRegenerated/restored by storage helpers.

Consistency and recovery rules

Worker exclusivity

Row locking prevents duplicate claims. A transaction advisory lock and live-processing check serialize different user rows in the same conversation. The worker intentionally claims with limit=1.

Idempotent transcript

Unique indexes permit at most one final assistant reply per user turn, one tool-request row per turn/iteration, one tool result per call id, and one compaction per triggering message.

Ownership before effect

The worker checks its lease before tool-request persistence and again before final reply persistence. An already-running external operation cannot be cancelled or fenced by PostgreSQL.

Non-destructive compaction

A summary stores the exact source span and retained boundary. Empty summaries are rejected in application code and by a database check. Full history endpoints continue to return raw rows.

Reconnect semantics

Saved final text and durable tool progress are replayable. Redis does not retain token events. On a page reload during generation, earlier unpersisted partial text is not replayed until a later full history reload.

Deletion ordering

Deletion returns 409 for a currently leased turn, but permits deletion of an untouched pending row or an abandoned row whose lease has expired.

5 · Function-level implementation guide

Functions in the order the system uses them

Each table is attached to an execution stage. Inputs and effects are abbreviated, but caller/callee and failure invariants are explicit.

Stage A Client entry, authentication, and HTTP coordination
Function · sourceConsumesOutput / effectRelationship and invariant
jsonFetchweb/src/lib/api.tsRelative API path, request optionsTyped JSON or ApiErrorBase transport for auth/history/conversation calls; always includes credentials.
login / logout / checkAuthStatusweb/src/lib/api.tsPassword or current cookieSession state; tool-cache clear on logoutCall gateway public auth routes. Native HTTP failure becomes ApiError.
createConversation / listConversations / deleteConversationweb/src/lib/api.tsClient/conversation idsConversation summaries or mutationThin typed wrappers; delete clears that conversation’s cached manifests.
sendMessageweb/src/lib/api.tsConversation id, client id, contentMessage id and stream URLCaller is App.handleSend; generation has not completed when it resolves.
getHistory / getBoundTools / fetchToolCallsweb/src/lib/api.tsConversation/turn idsGrouped history, manifests, detailed resultsTool manifests are cached per conversation; detailed results load on demand.
parseCookies / serializeCookiepackages/gateway/src/cookies.tsCookie header or name/value/optionsDecoded map or Set-Cookie stringUsed only by gateway auth helpers; encoding/attributes are centralized.
sign / createAuthToken / verifyAuthTokenpackages/shared/src/auth.tsExpiry payload, AUTH_SECRETHMAC-SHA256 token or booleanToken is stateless; verification uses constant-time signature comparison and expiry.
passwordMatches / isRateLimitedpackages/gateway/src/index.tsCandidate password; IP keyBoolean admission decisionPassword comparison is constant-time for equal lengths; throttle is process-local, 10 attempts/60s.
setAuthCookie / clearAuthCookie / isAuthenticatedpackages/gateway/src/index.tsResponse or requestCookie header or booleanCookie is HTTP-only, Secure, SameSite=Lax, 30-day signed expiry.
asyncHandlerpackages/gateway/src/index.tsAsync Express handlerExpress-safe callbackCatches rejections; emits 500 before headers or closes an already-started response.
mainpackages/gateway/src/index.tsEnvironment, pool, publisherInitialized schema and listening Express appAuth-public routes are registered before the cookie gate; schema initialization precedes listen.
send / deliver / cleanupnested in SSE routeInternal StreamEvent, request lifecycleNamed SSE frames; subscriber cleanupdone/error close stream. Cleanup is idempotent.
usageFromRow / groupMessagesForClientpackages/gateway/src/group-history.tsRaw rows and latest compactionClient messages with usage/tool summariesHides standalone tool rows and worker lease fields; legacy context is estimated from retained history, never cumulative prompt tokens.
Stage B Durable queue, transcript, and leases
Function · sourceConsumesOutput / effectRelationship and invariant
createPool / initSchemapackages/shared/src/db.tsDATABASE_URL; poolPool; idempotent schema/migrationsGateway calls schema init at startup. SQL creates extension, tables, checks, and unique indexes.
createConversation / getConversationpackages/shared/src/db.tsClient id or conversation idConversation row/nullUsed by route validation, startup selection, titling, and tool endpoints.
setConversationTitlepackages/shared/src/db.tsConversation id, generated titleConditional updateWHERE title IS NULL prevents retry races from overwriting a title.
listConversationspackages/shared/src/db.tsOptional client idRecent-first summary rowsLateral joins return last non-tool message and newest unresolved user job in one query.
insertUserMessagepackages/shared/src/db.tsConversation id, textPending user rowGateway calls this before Redis publish. The row is the worker job.
getMessage / getReply / getMessagesByReplyTopackages/shared/src/db.tsMessage/user-turn idOne row, final reply, or ordered turn rowsSSE replay and crash reconciliation distinguish final text from intermediate tool-request rows.
getConversationHistorypackages/shared/src/db.tsConversation idDeterministically ordered raw rowsParent timestamp + role ordering keeps each user turn and its replies adjacent.
sweepExhaustedLeasespackages/shared/src/db.tsMaximum attemptsExpired rows changed to failedclaimAndProcess calls it before claiming so crash loops terminate.
claimPendingMessagespackages/shared/src/db.tsWorker id, lease duration, limitOwned processing rowsUses SKIP LOCKED, expired-lease reclaim, conversation advisory lock, and live-turn exclusion. Production caller passes 1.
renewLease / stillOwnsLeasepackages/shared/src/db.tsMessage id and worker idBoolean ownership resultRenewal updates only the current processing owner; checks gate tool/final persistence.
markMessageDone / markMessageFailedpackages/shared/src/db.tsMessage id; bounded error textTerminal job statusBoth clear the lease; failure persists diagnosis across SSE loss/reload.
requeueMessageForRetrypackages/shared/src/db.tsUser message idFailed/expired row reset to pendingRefuses done/pending, non-user, or actively leased rows. Called by operator script.
deleteConversationpackages/shared/src/db.tsConversation id, optional client iddeleted | not_found | in_progressTransactional row locking refuses deletion while a live worker lease exists.
insertAssistantMessagepackages/shared/src/db.tsConversation/turn ids, text, usageFinal assistant rowUnique reply index + conflict handling ensures one final row and returns the winner.
insertToolCallRequestpackages/shared/src/db.tsTurn id, iteration, callsAssistant request rowCommitted before tool execution; one request row per turn/iteration.
insertToolResultpackages/shared/src/db.tsTurn id, call id/name/result/errorTool rowCommitted after each execution; duplicate call ids no-op.
insertCompaction / getLatestCompaction / getCompactionsForConversationpackages/shared/src/db.tsSummary checkpoint or conversation idStored/read compaction metadataBlank summaries are invalid; trigger id is an idempotency key.
acquireFileLock / renewFileLock / releaseFileLockpackages/shared/src/db.tsCanonical path, owner token, durationCross-process file leaseOwner-token comparisons prevent one caller releasing another’s lock.
streamChannel / createRedisClient / createRedisSubscriberpackages/shared/src/redis.tsMessage id; REDIS_URLChannel name and Redis connectionsSeparate subscriber connection is required by Redis Pub/Sub mode.
Stage C Worker orchestration, recovery, and model loop
Function · sourceConsumesOutput / effectRelationship and invariant
main / claimAndProcesspackages/worker/src/index.tsRedis wake-ups and 1s timerSequential claimed-turn tasksPrevents overlapping claim batches in one worker; sweeps then claims exactly one.
processMessagepackages/worker/src/index.tsClaimed user rowRecovery/direct/LLM branch; terminal status/eventOwns lifecycle, lease heartbeat, persistence hooks, error handling, and final ownership check.
maybeGenerateTitlepackages/worker/src/index.tsCompleted user/assistant textOptional conversation titleRuns before done; errors are non-fatal and a later turn retries.
shutdownpackages/worker/src/index.tsSIGTERM/SIGINTStop claims; drain; close clients/poolAfter the default 20s drain, remaining work is left for lease expiry/reclaim.
reconcileTurnStatepackages/worker/src/turn-recovery.tsTools, conversation/turn ids, user contentready | already-final | already-final-direct | blocked-mutationRetries only unresolved calls whose registered tool is explicitly read-only; unknown means unsafe.
parseDirectInvocation / resolveDirectArgspackages/worker/src/direct-tool.tsRaw /tool … text and schemaInvocation and argument objectParser does not authorize the name; caller must find it in enabled tools. JSON object or single-arg shorthand is accepted.
runDirectToolTurnpackages/worker/src/direct-tool.tsOwned row, bound tool, publisherOne request/result pair; empty-content doneNo LLM follow-up or final assistant text row. Ownership is checked at every durable boundary.
generateTitlepackages/worker/src/llm.tsFirst available completed turnSanitized title ≤60 characters or nullNon-streaming model call; prompt limits each side to 2,000 characters.
streamCompletionpackages/worker/src/llm.tsLast 20 raw history rows, token callbackAssembled basic replyLegacy/simple helper; production processMessage uses runToolLoop.
getDefaultClient / buildRunToolspackages/worker/src/tool-loop.tsProvider config; run lifecycle/deadlineLLM client and bound tool setSubagent is rebound per run so child events and deadline are scoped correctly.
executeToolCallpackages/worker/src/tool-loop.tsAccumulated call and registry{content,isError}Unknown tool, invalid JSON, thrown error, and non-string output are normalized for the model.
runToolCallSteppackages/worker/src/tool-loop.tsIteration, call, hooks, lifecycleTool record + model tool messageRuns before/after hooks and emits execution/message events around exactly one call.
isToolCallAssistantRowpackages/worker/src/tool-loop.tsDatabase message rowBoolean classifierShared by history reconstruction and recovery to distinguish intermediate assistant rows.
sliceHistoryAtTurnBoundariespackages/worker/src/tool-loop.tsRaw history and row limitStructurally valid history sliceMoves boundaries so tool request/result groups are never split.
rowToChatMessage / lifecycleMessageToChatMessage / chatMessagesToLifecyclepackages/worker/src/tool-loop.tsPersisted or hook messagesOpenAI/lifecycle message shapesPreserves raw tool arguments and call ids required by compatible APIs.
buildCompactedMessages / maybeSplitTurnpackages/worker/src/tool-loop.tsHistory/current loop context and compaction optionsSummary-overlay promptFirst handles historical turns; second handles an oversized still-open tool turn.
runLoopBodypackages/worker/src/tool-loop.tsHistory, tools, client, callbacks, hooks, persistenceFinal content, ordered tool exchange, usageAccumulates streamed calls by index; persists request before execution/result after; parallelizes only all-parallelSafe batches.
buildUsage / abortedResultnested in runLoopBodyRound usage/timing and abort flagUsage snapshot or stable fallback resultContext tokens describe the latest call, while prompt/completion totals accumulate across rounds.
assistantLifecycleMessage / maybeEmitToolStartnested in runLoopBodyCurrent streamed text/callsObserver snapshot and early tool signalTool start is emitted once when id/name become available, before full arguments may arrive.
runToolLooppackages/worker/src/tool-loop.tsHistory, stream callbacks, optionsRunLoopResultEmits turn lifecycle and races body against a 720s backstop (600s loop deadline + 120s headroom).
Lifecycle.on / Lifecycle.emitpackages/worker/src/lifecycle.tsListener or typed eventUnsubscribe closure; ordered awaited notificationA throwing listener fails the emit and participates in turn settlement.
createMessageSignalLogger / createLoggingHookspackages/worker/src/lifecycle.tsLoggerObservational listener/hooksCurrent production hooks log but do not patch, stop, or inject.
Stage D Context budgeting and compaction
Function · sourceConsumesOutput / effectRelationship and invariant
estimateTokens / estimateMessageTokens / estimateMessagesTokenspackages/shared/src/context-estimate.tsText or compactable messagesConservative integer estimateCounts message overhead and structured tool payloads; used when provider usage is absent.
estimateHistoricalContextTokenspackages/shared/src/context-estimate.tsHistory, reply id, latest compactionLegacy reply context occupancyReconstructs retained prompt; never substitutes cumulative multi-round prompt usage.
shouldCompactpackages/worker/src/compaction.tsPrompt messages, thresholdBooleanDefault threshold is 40,000 estimated tokens.
findTurnCutPointpackages/worker/src/compaction.tsHistory, keep budget, prior boundaryUser-row cut or nullNever cuts inside a completed turn or removes the newest user row.
findAssistantCutPoint / findLastUserIndexpackages/worker/src/compaction.tsOpen-turn model messagesSafe assistant boundaryNever cuts at a tool row; locates only the active turn.
applyCompactionpackages/worker/src/compaction.tsMessages, summary message, cut indexPrompt overlayPreserves system/existing-summary prefix and replaces only the selected span in memory.
serializeRow(s) / serializeChatMessage(s)packages/worker/src/compaction.tsDB or model messagesBounded transcript textIncludes tool names/arguments/results so summaries retain operational facts.
buildSummarizePrompt / buildTurnPrefixPromptpackages/worker/src/compaction.tsRows or open-turn prefix; optional prior summarySummarizer user promptPrompts request factual state, decisions, errors, paths, and unresolved work.
runSummarization / summarize / summarizeTurnPrefixpackages/worker/src/compaction.tsSummarization client and promptTrimmed summary plus usageEmpty/blank output is a failed compaction; caller continues with uncompacted context.
Stage E Tool registry, execution, and file coordination
Function · sourceConsumesOutput / effectRelationship and invariant
toolEnabled / enabledToolManifests / manifestToJsonSchemapackages/shared/src/tools.tsManifest name/environmentEnabled UI list and model schemaTOOL_MANIFESTS is the shared source of name/arguments/read-only metadata.
DEFAULT_TOOLS / DEFAULT_SUBAGENT_TOOLpackages/worker/src/tools/index.tsEnvironment gatesWorker registryDatetime, calculator, and subagent are always enabled; bash/file tools are opt-in.
tokenizeExpression / ExpressionParser / evaluateExpressionpackages/worker/src/tools/calculator.tsArithmetic expressionFinite JavaScript number or validation errorRecursive descent supports + − × ÷ ^, parentheses, unary signs, and sqrt; never uses eval.
getCurrentDatetimeTool.executepackages/worker/src/tools/datetime.tsOptional IANA timezoneFormatted current date/timeRead-only; invalid timezone becomes a normalized tool error.
buildBashExecArgs / runBash / createBashToolpackages/worker/src/tools/bash.tsShell script; optional spawn/timeoutCombined output or errorRuns sudo -n -u ubuntu -- env … bash -s in /home/ubuntu; 20s default timeout, 4,000-char cap; not read-only.
resolveReadPath / resolveWritePathpackages/worker/src/tools/file-path-jail.tsRaw path and allowed rootValidated real/absolute pathRejects literal .. and symlink escape from /home/ubuntu; this is path containment, not an OS sandbox.
canonicalLockKeypackages/worker/src/tools/file-path-jail.tsValidated target pathStable real-path identityExisting and new paths converge on the same cross-process lock key.
truncateHead / createReadFileToolpackages/worker/src/tools/read-file.tsPath, optional 1-based offset/limitAnnotated UTF-8 excerptCaps output at 200 lines or 4,000 characters and gives a continuation offset.
writeFileAtomicpackages/worker/src/tools/atomic-write.tsTarget path and full contentSibling temp file renamed over targetDeletes temp file if rename fails; rename avoids partial in-place content.
createPostgresFileLock / withFileLockpackages/worker/src/tools/file-lock.tsCanonical key, async mutationBounded exclusive lease around mutationBackoff up to 20s, 15s lease/5s heartbeat. Lease loss prevents a clean-success report but cannot fence a syscall already in progress.
sha256Hex / createWriteFileToolpackages/worker/src/tools/write-file.tsPath, full content, optional expected hashAtomic create/overwriteLock covers read/compare/write. Expected hash rejects stale overwrite.
countOccurrences / createEditFileToolpackages/worker/src/tools/edit-file.tsPath, exact old/new stringsOne atomic replacement plus diffRequires exactly one match after acquiring lock and re-reading; empty/multiple/missing matches fail.
createSubagentTool / resolveChildToolspackages/worker/src/tools/subagent.tsSelf-contained task/context, optional tool namesNested runToolLoop final textChild sees no parent history; requested names form an allowlist; default excludes subagent itself.
createForwardingListener / formatSubagentResultpackages/worker/src/tools/subagent.tsChild lifecycle/resultTagged parent events and self-contained resultRun id/depth scopes events; sibling subagent calls are marked parallelSafe.
Stage F Browser state, streaming, and rendering
Function · sourceConsumesOutput / effectRelationship and invariant
Appweb/src/App.tsxAuth, storage, API, reducer stateComplete chat applicationSelected conversation is separate from the per-conversation data/stream store.
dispatchSyncweb/src/App.tsxReducer actionImmediate ref update + React dispatchCloses the rapid double-submit gap caused by waiting for a render.
beginStreamweb/src/App.tsxConversation/turn ids and stream URLEventSource wired to reducerWorks for selected or background chats; estimates live tokens/sec from accumulated text.
loadConversation / ensureLoadedweb/src/App.tsxConversation idHistory state and possible stream attachmentDeduplicates initial loads; turns permanent failure into an assistant-shaped diagnostic.
ensureOutstandingAttached / refreshConversationsweb/src/App.tsxSidebar outstanding-turn metadataRepaired background streamsChecks attachment to the specific outstanding message, not merely loaded history.
init / handleLoginSuccess / handleLogoutweb/src/App.tsxAuth and saved selectionRestored/created session or clean resetLogout closes all stream handles before reducer reset.
handleSelectConversation / handleNewChat / handleDeleteConversationweb/src/App.tsxUser navigation actionsSelection and conversation mutationsDeletion chooses a surviving conversation or creates a new one.
handleSendweb/src/App.tsxText and selected conversationOptimistic user/assistant rows + streamBlocks only the same conversation using synchronous submitting then confirmed sendingMessageId.
isServerSentErrorEvent / streamReplyweb/src/lib/api.tsEventSource events and handlersToken/tool/done/error callbacks; closerDistinguishes server event:error from native connection failure; native reconnect remains enabled.
appendTextStep / estimateVisibleTokens / findOutstandingUserMessageweb/src/lib/conversationStore.tsStream steps, text, historyCoalesced steps, estimate, unresolved rowOutstanding detection uses user-row status, so an intermediate assistant tool row does not hide active work.
conversationsReducerweb/src/lib/conversationStore.tsPer-conversation actionsImmutable ConversationsStateIsolates concurrent streams/errors and clears stream handles on terminal events/removal/reset.
matchTools / buildArgsJson / buildCommand / resolveFreehandweb/src/lib/slashTools.tsTyped slash token/form values/manifestsRanked tools and literal command textMulti-argument calls require explicit form; quick freehand path is limited to read-only tools by Composer.
getOrCreateClientId / conversation and sidebar helpersweb/src/lib/storage.tsBrowser localStorageStable random client id and preferencesConvenience scoping only; clearing storage creates another client identity.
useMediaQueryweb/src/lib/useMediaQuery.tsMedia query stringReactive booleanDrives sidebar drawer/collapse behavior.
Composer + input/argument handlersweb/src/components/Composer.tsxText, manifests, disabled stateNormal text or slash commandKeyboard picker and arg form reset after accepted submit; missing required args are allowed to become tool errors.
groupSteps / MessageBubbleweb/src/components/MessageBubble.tsxMessage and live ordered stepsMarkdown/text/tool bubble groupsPreserves text → tool → text ordering during a live turn.
ToolCalls + toggleweb/src/components/ToolCalls.tsxTool summaries and turn idStatus chips and lazy detailsCompleted results are fetched once into component cache; running calls cannot expand.
computeContextStats / formatContextLabel / StatsBarweb/src/components/StatsBar.tsxRendered messages and sending stateContext occupancy and live/average speedUses latest contextTokens against a hand-synchronized 270k capacity, not cumulative usage.
Sidebar + pointer handlersweb/src/components/Sidebar.tsxConversation summaries and callbacksSelection, status, deletion UITouch deletion requires a 500ms long press with movement tolerance.
MessageListweb/src/components/MessageList.tsxMessages, compactions, loadingOrdered transcript and automatic scrollThreads conversation id to tool detail rendering.
relativeDate / CompactionNotesweb/src/components/CompactionNotes.tsxNewest-first compaction metadataChronological checkpoint notesPresentation only; does not change message history.
LoginScreenweb/src/components/LoginScreen.tsxPassword inputAuthenticated transition or visible errorUses cookie-based login wrapper; password is not stored client-side.

6 · Tools

Tools: contracts, mechanics, and consequences

A tool is a typed operation the worker may execute during a turn. The manifest describes it; the registry enables it; the tool loop makes every request and result durable. The model can select tools, but never executes them itself.

sequenceDiagram
    autonumber
    participant C as Caller
    participant L as Tool loop
    participant D as PostgreSQL
    participant R as Tool registry
    participant T as Tool implementation
    participant E as Redis and UI
    C->>L: Model tool call or direct slash invocation
    L->>D: Persist tool-call request
    L->>E: Publish tool start
    L->>R: executeToolCall with name and arguments
    R->>R: Find implementation and parse JSON
    R->>T: Execute validated arguments
    T-->>R: Return content or throw
    R-->>L: Return normalized ToolResult
    L->>D: Persist tool result
    L->>E: Publish tool end
    alt Model-selected call
      L-->>C: Append result to model context
    else Direct slash call
      L-->>C: Finish without model follow-up
    end

The shared mechanism

1. Describe

TOOL_MANIFESTS owns names, descriptions, arguments, and readOnly. manifestToJsonSchema converts that contract to model JSON Schema, keeping gateway, composer, and worker definitions aligned.

2. Enable

DEFAULT_TOOLS binds manifests to functions. Date/time, calculator, and subagent are always present; file and Bash tools require environment flags. The runtime registry, not the visible UI, is authoritative.

3. Invoke

runToolLoop assembles streamed model calls by index. A recognized /name uses runDirectToolTurn; JSON follows the name, while plain text maps to a sole argument. Unknown names stay ordinary chat.

4. Normalize

executeToolCall looks up the tool and parses JSON. Unknown names, malformed input, validation errors, and exceptions become isError results. Empty successful output becomes (empty result).

5. Record

The worker commits requested calls before execution and each result before another model round. Redis tool_start/tool_end events are live progress; PostgreSQL rows are the durable audit trail.

6. Recover

Missing results may be regenerated only for tools marked readOnly. Mutating or unknown calls block until an operator verifies the external effect, records it with resolve-tool-call.ts, and requeues the message.

Tool index and execution boundary
ToolPurposeEnablementEffect
get_current_datetimeRead and optionally localize the current instant.AlwaysRead-only
calculatorEvaluate a bounded arithmetic grammar.AlwaysRead-only
subagentRun a clean-context nested model/tool loop.AlwaysPotentially mutating
read_fileRead a bounded UTF-8 file slice.TOOL_READ_FILE_ENABLED=trueRead-only, sensitive
write_fileAtomically replace a file.TOOL_WRITE_FILE_ENABLED=trueMutating
edit_fileReplace one exact string.TOOL_EDIT_FILE_ENABLED=trueMutating
bashRun Bash as the host ubuntu account.TOOL_BASH_ENABLED=trueUnrestricted

get_current_datetime

always enabledread-onlyretryable

Purpose and input

Returns one current timestamp. Optional timezone names an IANA zone such as Europe/London. No non-empty string means UTC ISO 8601.

Output and errors

Returns a string. Invalid zones become a targeted tool error. A recovery retry is safe, but naturally observes a later instant.

Mechanism

  1. Create exactly one Date so date and time agree.
  2. Without a zone, call toISOString().
  3. Otherwise construct Intl.DateTimeFormat with full date, long time, and the requested zone.
  4. Format the saved instant; translate formatter rejection to Invalid timezone.

Source: packages/worker/src/tools/datetime.ts

calculator

always enabledread-onlyno eval

Purpose and input

Evaluates non-empty expression text without executing JavaScript. Grammar: numbers, whitespace, + - * / ^, unary signs, parentheses, and sqrt(...).

Output and errors

Returns the number as a string. It rejects unknown characters/functions, malformed numbers, missing parentheses, trailing tokens, division by zero, and negative square roots.

Mechanism

  1. tokenizeExpression scans numbers, names, operators, parentheses, then appends EOF.
  2. ExpressionParser descends through expression, term, power, unary, and primary precedence.
  3. Power recurses on the right, making 2^3^2 right-associative.
  4. Primary handles numbers, groups, and the sole function sqrt.
  5. An EOF requirement prevents valid prefixes from hiding trailing input.

Source: packages/worker/src/tools/calculator.ts

subagent

always enabledclean contextparallel-safe siblingsnot read-only

Purpose and input

Runs a focused child task through the same loop and returns its final answer. task is required; context and requested tools are optional.

Isolation and limits

The child receives one synthetic message containing only task and context, not parent history. Default child tools exclude subagent; depth is capped at three. Unknown requested names are filtered, but an empty selection falls back to the configured base set.

Mechanism

  1. Build the isolated child message and resolve child tools.
  2. Create child run ID/lifecycle and a timeout of at least 30 seconds, normally 70% of the parent timeout.
  3. Call runToolLoop recursively while suppressing child token streaming.
  4. Forward child tool events with run ID and depth; send short result previews as progress.
  5. Return final text with child run ID, depth, and tool-step count.

Recovery

A child may mutate, so missing results are not auto-replayed. Siblings may run concurrently, while their parent results remain persisted in model order.

Source: packages/worker/src/tools/subagent.ts, tools/index.ts

Shared file boundary

The three file tools are rooted at /home/ubuntu. Absolute and relative paths are accepted; legacy /repo maps into that root. A .. component is rejected. Reads resolve the full real path; writes resolve the nearest existing ancestor, permitting new files while blocking symlink escape.

flowchart LR
    PATH["Validate path and real ancestor"] --> KEY["Canonical lock key"]
    KEY --> LOCK["Acquire PostgreSQL file lease"]
    LOCK --> READ["Read current state in lease"]
    READ --> CHECK{"Precondition valid?"}
    CHECK -->|no| FAIL["Tool error"]
    CHECK -->|yes| OWN["Check turn and lease"]
    OWN --> TEMP["Write sibling temporary file"]
    TEMP --> RENAME["Atomic rename"]
    RENAME --> RELEASE["Release in finally"]
    SHELL["bash writes"] -. "bypasses lease" .-> RENAME

read_file

opt-inread-onlyhome-root jailedsensitive

Purpose and input

Returns a bounded UTF-8 view. Input is path plus optional positive offset/limit; offset is one-based. Dotfiles and other projects under the root remain readable.

Output and errors

Returns an XML-like <file> block with line range. Missing files, traversal, symlink escape, and invalid paging fail. Buffers are decoded as UTF-8; there is no binary mode.

Mechanism

  1. Normalize path, apply /repo, call realpath, and verify containment.
  2. Read the full buffer and decode UTF-8.
  3. Apply the requested one-based line slice.
  4. Cap output at 200 lines or 4,000 characters.
  5. If content remains, append the next offset and truncation reason.

Recovery

Auto-retryable, but retry is a fresh read rather than a snapshot replay.

Source: packages/worker/src/tools/read-file.ts, file-path-jail.ts

write_file

opt-inmutatingleasedatomic rename

Purpose and input

Replaces a file with complete content, creating parents. Optional expected_hash is SHA-256 of existing UTF-8 text.

Concurrency

The hash is an optimistic precondition for an existing file; it is not checked for a new target. The database lease coordinates file tools only. Bash and outside processes bypass it.

Mechanism

  1. Resolve the write path and derive a canonical key from the real target or ancestor.
  2. Acquire the PostgreSQL lease, create parents, and read current text inside it.
  3. If supplied, compare expected_hash with sha256Hex(existing).
  4. Check turn ownership and known lease loss just before mutation.
  5. Write a hidden sibling temp file, rename it over the target, and clean up on failure.

Lease and recovery

Defaults: 15-second lease, 5-second heartbeat, 20-second bounded acquisition. Known lease loss suppresses success but cannot fence an in-flight syscall. Crashes require manual verification.

Source: write-file.ts, file-lock.ts, atomic-write.ts

edit_file

opt-inmutatingexactly-one matchatomic rename

Purpose and input

Replaces one exact occurrence and returns a compact line diff. Requires path, non-empty old_string, and new_string.

Concurrency

No hash argument. Protection comes from a fresh read after locking plus the exactly-one-match rule. Zero or multiple matches fail.

Mechanism

  1. Resolve the target and acquire the shared canonical file lease.
  2. Read in the lease and count non-overlapping matches with repeated indexOf.
  3. Require exactly one, then splice prefix, replacement, and suffix.
  4. Check ownership and lease state immediately before writing.
  5. Atomically rename a sibling temp file and return -/+ lines.

Recovery

A rename may finish before a crash hides the result. Repeating could affect newer content, so recovery blocks for verification.

Source: edit-file.ts, file-lock.ts, atomic-write.ts

bash

opt-inunrestricted host executionmutating20s timeout

Purpose and input

Runs non-empty command text on the host. It is not a container, seccomp profile, filesystem jail, or allowlist. The body is sent through stdin, not interpolated into the launcher.

Identity

Fixed launcher: sudo -n -u ubuntu -- env HOME=/home/ubuntu PATH=... bash -s, cwd /home/ubuntu. Fixed wrapper arguments do not limit what the Bash body may do with that account's permissions.

Mechanism

  1. Spawn sudo with fixed arguments and piped standard streams.
  2. Write the command to stdin; combine stdout and stderr.
  3. Race process completion against the default 20-second timer.
  4. On non-zero exit, throw with exit code and captured output.
  5. Return at most 4,000 characters with a truncation suffix.

Critical boundary

The timer rejects waiting but does not explicitly kill the child. File leases do not cover shell writes. Crash or timeout can leave effects without a durable result; inspect the host before resolution.

Source: packages/worker/src/tools/bash.ts

Direct versus model-selected

PropertyModel-selectedDirect slash
SelectionModel chooses enabled schemas with tool_choice=auto.User names a registered tool.
ArgumentsStreamed fragments accumulate by call index./name {json}, or plain suffix for one argument.
After resultAppend to model context; model may call again or answer.Mark done with empty assistant content and no LLM usage.
DurabilityBoth persist request before execution and result afterward, with identical lifecycle events.
The tools UI is not an authorization boundary. The server executes syntactically valid direct calls whose names are in DEFAULT_TOOLS. Enable file and Bash tools only for trusted operators; treat read_file as access to secret material inside the allowed root.

7 · Operations and debugging

Run, observe, and recover

Commands below are taken from repository manifests. The web client is a separate Vite process; root npm run dev starts only gateway and one worker.

1. Configure .env

OPENAI_API_KEY=…
AUTH_PASSWORD=…
AUTH_SECRET=…
# optional:
OPENAI_BASE_URL=https://api.openai.com
OPENAI_MODEL=gpt-4o-mini

2. Install and start state services

npm install
npm run infra:up

Runs docker-compose up -d, then waits up to 60s for PostgreSQL :5433 and Redis :6380.

3. Start backend and capacity

npm run dev
# optional additional worker:
npm run dev:worker

Gateway listens on :3000. Each worker processes one turn at a time.

4. Start the web client

cd web
npm install
npm run dev

Current Vite config binds 127.0.0.1:4827 and proxies /api to :3000. web/README.md still says 5173 and is stale.

Health and focused checks

curl -s http://localhost:3000/health
npm test -w packages/gateway
npm test -w packages/worker
npm test --prefix web
npm run build --prefix web

# live stack + real configured model required:
python3 scripts/e2e-sse-tool-loop.py
Database-test guard. PostgreSQL-backed worker tests require a disposable database whose name contains test. Create chat_test once, then use npm run test:db -w packages/worker. Those tests truncate shared tables.
Debugging matrix
SymptomInspectLikely boundaryNext action
POST succeeds; no worker activityUser row status; worker subscription/log; Redis healthWake-up may be missed, but poll should find rowWait one poll; confirm worker logs and DB connectivity.
Row stays processingworker_id, lease_expires_at, attempt_countWorker stalled/crashed or lease renewals failingDo not requeue a live lease. Let expiry/reclaim or diagnose worker.
Turn is failedlast_error; worker log prefix msg=<id>Provider/tool/persistence/recovery failureFix cause, then run npx tsx scripts/requeue-message.ts <messageId>.
Failure mentions unresolved mutating callTurn rows and external target (file/command effect)Crash after possible effect but before result commitVerify reality, record it with resolve-tool-call.ts, then requeue.
SSE closes with server errorNamed error event and persisted last_errorWorker terminal failureUse message id to correlate gateway/worker/database.
SSE reconnects but partial text is missing after reloadFinal reply row and active statusRedis tokens are transient; partial assistant text is not checkpointedWait for completion, then reload history. See documented reconnect limitation.
Conversation delete returns 409Live processing leaseIntentional delete/worker race guardWait for done/failed/expiry, then retry.
File mutation times outfile_locks row and tool errorAnother worker holds the canonical path leaseWait/retry; re-read before applying stale edits.
Context number looks largecontext_tokens vs cumulative prompt_tokensUsage semanticsUI must display latest-call context, not multi-round sum.

Manual recovery for an ambiguous tool effect

  1. Read the user row’s last_error and locate the unresolved toolCallId.
  2. Inspect the real external target. Do not assume the tool did or did not run.
  3. Record the verified outcome: npx tsx scripts/resolve-tool-call.ts <messageId> <toolCallId> "<result>" [--error].
  4. Requeue: npx tsx scripts/requeue-message.ts <messageId>.
  5. Watch the worker resume at the next durable iteration instead of repeating the mutation.
Known stale verifier: scripts/verify-bash-executor.py expects uid=1000(opc) and a sealed .env, but current bash.ts runs as ubuntu with HOME=/home/ubuntu and describes unrestricted host access. Do not use that script as proof of the current trust model until it is updated.

8 · Trust boundaries and limitations

What is enforced, what is trusted

Correctness guarantees are strongest inside PostgreSQL. Authentication, browser identity, and host tools are intentionally lightweight or trusted.

Guarantee matrix
BoundaryCode-enforcedTrusted / advisoryNot guaranteed
Turn ownershipAtomic claim, lease owner checks, unique reply/tool indexes, max attemptsWorkers use claim limit 1 and keep heartbeat responsiveCancellation/fencing of a tool syscall already running
OrderingConversation advisory lock and live-turn exclusionCurrent single-turn worker loopCorrectness if claim limit is raised without redesign
DeliveryPersist-before-publish; subscribe-before-query; final reply replayRedis availability for smooth live progressReplay of every token or partial assistant text from Redis
AuthenticationSigned expiring cookie; shared password; timing-safe checks; Secure/HTTP-only/SameSiteOne trusted user population and protected network edgeUser accounts, roles, distributed rate limiting, revocation list, CSRF tokens
Client scopingConversation listing/deletion can filter by local clientIdBrowser-generated id remains honestclientId is not an authorization boundary; authenticated callers can choose it, and direct history/tool routes do not apply ownership checks.
File toolsLiteral traversal/symlink containment under /home/ubuntu, canonical locks, exact edit match, optional expected hash, atomic renameThe model/operator requests appropriate paths and changesCredential secrecy inside the allowed root; filesystem-level fencing after lease loss
BashNon-empty command, 20s promise timeout, 4,000-char returned-output capTrusted operator intentSandboxing. It runs as ubuntu, whose documented host has passwordless sudo and Docker access; timeout does not explicitly kill the spawned child.
CompactionRaw rows preserved; non-blank constraint; idempotent trigger; safe cut pointsModel summary accurately preserves old factsLossless semantic summarization
ProviderTimeout backstop, malformed-call normalization, usage fallbackOpenAI-compatible streaming/tool-call behaviorAvailability, deterministic output, or exact usage reporting

Deployment posture

Use only for a trusted operator. Do not expose enabled bash/write tools to untrusted users. Network, TLS, reverse-proxy, secret management, backups, and production observability are outside this repository.

Authentication scope

The single shared password gates the gateway. It does not model principals, resource ownership, permissions, or audit identity. DISABLE_CLIENT_SCOPING=true intentionally makes conversation lists shared.

Streaming scope

Named lifecycle events are exposed by the gateway, but the current web client registers only token, tool start/end, done, and error handlers. Other named events are ignored by browser code.

Process health

/health reports gateway liveness, not PostgreSQL, Redis, worker, or model-provider readiness. Infrastructure health checks live in Docker Compose and the startup wait script.

9 · Extension guide

Where changes belong

Keep shared contracts authoritative, add durable state before presentation, and extend each cross-layer event end to end.

Common extension paths
ChangeImplementation pathRequired checks
Add a toolDefine manifest in packages/shared/src/tools.ts → implement Tool in packages/worker/src/tools/ → export/register in tools/index.ts → add env gate in shared/src/env.ts if sensitive.Set readOnly conservatively; add drift/schema tests and execution/recovery tests. Slash UI consumes manifests automatically.
Add an API routeAdd shared query/type if stateful → register behind the appropriate auth gate in gateway/src/index.ts → add typed wrapper in web/src/lib/api.ts.Validate resource relationship and client scope explicitly; add route/unit tests and 401/404/409 behavior.
Add an SSE eventExtend StreamEvent in shared/src/types.ts → publish in worker/lifecycle → map in gateway deliver → handle in web/src/lib/api.ts and reducer/component.Keep payload bounded; ensure named-event backward compatibility; test native-vs-server error behavior.
Add a database field/entityAdd idempotent SQL to SCHEMA_SQL → update shared types and reads/writes → shape gateway response → update web type/reducer/UI.Preserve legacy null handling, unique/idempotency rules, delete cascades, and disposable DB tests.
Change worker policyPrefer LifecycleHooks for pre/post tool decisions, stop policy, or next-turn injection. Change runToolLoop only for core protocol behavior.Test lifecycle ordering, timeout/abort paths, persistence boundaries, and recovery across a restart.
Add a web featurePut transport in web/src/lib/api.ts, shared per-chat transitions in conversationStore.ts, orchestration in App.tsx, and presentation in components/.Test simultaneous conversations, switch-mid-stream, cleanup, rapid actions, and reload/resume.
Change context strategyUpdate shared estimates, compaction.ts cut/serialization rules, then tool-loop.ts prompt construction and persisted checkpoint metadata.Never split tool request/result structure; preserve raw history; verify legacy usage fallback.

Review checklist

  • Does durable state commit before the event that announces it?
  • Can a retry create duplicate transcript rows or repeat an external effect?
  • Does a lease/lock loss stop later persistence and avoid claiming clean success?
  • Can history slicing or compaction separate a tool request from its result?
  • Does the gateway enforce the intended resource/client boundary, not only the UI?
  • Are new events bounded, typed, and ignored safely by older clients?
  • Are concurrent conversations and page-reload recovery covered by tests?
  • Does documentation describe current source rather than an earlier rollout phase?

Source notes

Evidence and known documentation drift

This guide was derived from the current production source, package manifests, Docker Compose file, tests, operator scripts, feature notes, and the previous standalone guide. Source code and tests were treated as authoritative when older prose disagreed.

  • web/vite.config.ts currently uses port 4827; web/README.md says 5173.
  • packages/worker/src/tools/bash.ts currently executes as ubuntu; scripts/verify-bash-executor.py and an atomic-write.ts comment still describe opc.
  • Some feature documents describe earlier phases or superseded persistence behavior. The implemented schema and tests cover durable tool rows, recovery, compaction, and multi-conversation streaming.
  • Live model/provider behavior and live Docker/database integration are environment-dependent and must be verified with the supplied live/database commands.