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.
Project introduction
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.
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.
The repository includes a React client, Express gateway, PostgreSQL/Redis infrastructure, an LLM worker, built-in host tools, durable compaction, and operator recovery scripts.
Engineers reviewing, running, debugging, or extending the system. It is a demonstration and trusted single-operator deployment, not a hardened multi-tenant service.
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.
2 · System map
PostgreSQL owns durable truth; Redis carries transient signals. Boxes show the deployment parts, while connecting labels show the functions that advance a turn.
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
| Part | Main functions | Mechanism | State boundary |
|---|---|---|---|
| Web state | App, handleSend, conversation reducer | Keys messages, active streams, errors, and submission guards by conversation so background turns remain independent. | Browser memory; client id and selection in localStorage. |
| Web transport | jsonFetch, sendMessage, getHistory, streamReply | Converts UI operations to authenticated REST; maps named SSE events into reducer callbacks and exposes a close function. | No authoritative transcript state. |
| Gateway auth and routes | main, isAuthenticated, passwordMatches, route handlers | Validates the shared-password cookie and request shape; persists user input before publishing a wake-up. | Stateless except process-local login throttling. |
| Gateway SSE relay | send, deliver, cleanup | Subscribes before checking PostgreSQL, buffers the race window, replays durable progress, then relays live named events. | Connection-local buffer only; reconnects consult PostgreSQL. |
| PostgreSQL adapter | insertUserMessage, claimPendingMessages, renewLease, stillOwnsLease | Uses 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 adapter | createRedisClient, createRedisSubscriber, streamChannel | Publishes one global wake-up channel and message-specific progress channels. | Transient acceleration only; events are not replayed. |
| Worker scheduler | claimAndProcess, processMessage, reconcileTurnState | Polls as a fallback, claims one turn, renews ownership, reconciles incomplete work, and settles done/failed state. | Replaceable process; durable progress stays in PostgreSQL. |
| Model context | buildCompactedMessages, shouldCompact, summarize | Builds a bounded prompt from raw rows and the newest additive compaction checkpoint. | Raw history remains immutable; summaries are prompt metadata. |
| Model and tools | runToolLoop, executeToolCall, DEFAULT_TOOLS | Streams completions, assembles tool deltas, validates dispatch, and checkpoints each call before and after execution. | Provider and host are external-effect boundaries. |
packages/sharedCross-process types, schema/queries, environment parsing, Redis names, auth signing, tool manifests, and context estimates.
packages/gatewayHTTP authentication and validation, conversation endpoints, client-facing history projection, and race-safe SSE relay.
packages/workerPolling/claims, renewable leases, crash reconciliation, tool loop, compaction, lifecycle events, and built-in tools.
webAuthentication screen, local client identity, per-conversation reducer, background streams, slash-tool UI, and transcript rendering.
scriptsInfrastructure readiness, live SSE checks, and explicit operator recovery for failed or ambiguous turns.
3 · Primary workflow
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
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
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
The gateway returns conversationId, messageId, and a message-specific stream URL. Model latency is outside the POST lifecycle.
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.
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.
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.
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.
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.
The reducer keys history, stream handles, submission guards, and errors by conversation id. A background conversation continues streaming while another is selected.
| Method and path | Authentication | Purpose and response |
|---|---|---|
POST /login | Public; rate-limited per process/IP | Validate shared password; set signed cookie; 204. |
POST /logout | Public | Expire cookie; 204. |
GET /auth/status | Public | 200 authenticated or 401. |
GET /health | Public | Process liveness only: {"ok":true}. |
POST /conversations | Cookie | Create by clientId; return id. |
GET /conversations?clientId=… | Cookie | Recent-first summaries and outstanding-turn metadata. |
DELETE /conversations/:id | Cookie | 204; 404; or 409 while a live processing lease exists. |
POST /conversations/:id/messages | Cookie | Persist pending user row; publish wake-up; return ids/stream URL. :id may be new. |
GET /conversations/:id/messages | Cookie | Grouped full history plus compaction notes. |
GET /conversations/:id/tools | Cookie | Enabled tool manifests. |
GET /conversations/:id/messages/:messageId/tools | Cookie | Full tool arguments/results for one user turn. |
GET /conversations/:id/messages/:messageId/stream | Cookie | SSE before, during, or after processing. |
4 · State and data model
The same messages table holds conversation content and the user rows that workers claim as jobs.
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 --> [*]
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 | Location | Lifetime | Recovery behavior |
|---|---|---|---|
| Conversation and raw transcript | PostgreSQL conversations, messages | Durable | Reloaded after process/browser restart; compaction never edits or deletes raw rows. |
| Job ownership | User message columns: status, worker, lease, attempts, error | Durable renewable lease | Expired work is reclaimable; exhausted work becomes failed. |
| Tool progress | Assistant request rows + tool result rows | Durable per iteration/call | Reconciliation resumes at the next safe checkpoint. |
| Context summary | compactions | Durable additive metadata | Newest checkpoint overlays old prompt context only. |
| Wake-ups and tokens | Redis Pub/Sub | Transient | Polling recovers missed jobs; PostgreSQL returns completed replies. |
| Active UI stream | Browser memory, per conversation | Transient | Conversation list/history finds outstanding jobs and reattaches. |
| Client identity and selection | Browser localStorage | Device-local | Regenerated/restored by storage helpers. |
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.
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.
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.
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.
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 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
Each table is attached to an execution stage. Inputs and effects are abbreviated, but caller/callee and failure invariants are explicit.
| Function · source | Consumes | Output / effect | Relationship and invariant |
|---|---|---|---|
jsonFetchweb/src/lib/api.ts | Relative API path, request options | Typed JSON or ApiError | Base transport for auth/history/conversation calls; always includes credentials. |
login / logout / checkAuthStatusweb/src/lib/api.ts | Password or current cookie | Session state; tool-cache clear on logout | Call gateway public auth routes. Native HTTP failure becomes ApiError. |
createConversation / listConversations / deleteConversationweb/src/lib/api.ts | Client/conversation ids | Conversation summaries or mutation | Thin typed wrappers; delete clears that conversation’s cached manifests. |
sendMessageweb/src/lib/api.ts | Conversation id, client id, content | Message id and stream URL | Caller is App.handleSend; generation has not completed when it resolves. |
getHistory / getBoundTools / fetchToolCallsweb/src/lib/api.ts | Conversation/turn ids | Grouped history, manifests, detailed results | Tool manifests are cached per conversation; detailed results load on demand. |
parseCookies / serializeCookiepackages/gateway/src/cookies.ts | Cookie header or name/value/options | Decoded map or Set-Cookie string | Used only by gateway auth helpers; encoding/attributes are centralized. |
sign / createAuthToken / verifyAuthTokenpackages/shared/src/auth.ts | Expiry payload, AUTH_SECRET | HMAC-SHA256 token or boolean | Token is stateless; verification uses constant-time signature comparison and expiry. |
passwordMatches / isRateLimitedpackages/gateway/src/index.ts | Candidate password; IP key | Boolean admission decision | Password comparison is constant-time for equal lengths; throttle is process-local, 10 attempts/60s. |
setAuthCookie / clearAuthCookie / isAuthenticatedpackages/gateway/src/index.ts | Response or request | Cookie header or boolean | Cookie is HTTP-only, Secure, SameSite=Lax, 30-day signed expiry. |
asyncHandlerpackages/gateway/src/index.ts | Async Express handler | Express-safe callback | Catches rejections; emits 500 before headers or closes an already-started response. |
mainpackages/gateway/src/index.ts | Environment, pool, publisher | Initialized schema and listening Express app | Auth-public routes are registered before the cookie gate; schema initialization precedes listen. |
send / deliver / cleanupnested in SSE route | Internal StreamEvent, request lifecycle | Named SSE frames; subscriber cleanup | done/error close stream. Cleanup is idempotent. |
usageFromRow / groupMessagesForClientpackages/gateway/src/group-history.ts | Raw rows and latest compaction | Client messages with usage/tool summaries | Hides standalone tool rows and worker lease fields; legacy context is estimated from retained history, never cumulative prompt tokens. |
| Function · source | Consumes | Output / effect | Relationship and invariant |
|---|---|---|---|
createPool / initSchemapackages/shared/src/db.ts | DATABASE_URL; pool | Pool; idempotent schema/migrations | Gateway calls schema init at startup. SQL creates extension, tables, checks, and unique indexes. |
createConversation / getConversationpackages/shared/src/db.ts | Client id or conversation id | Conversation row/null | Used by route validation, startup selection, titling, and tool endpoints. |
setConversationTitlepackages/shared/src/db.ts | Conversation id, generated title | Conditional update | WHERE title IS NULL prevents retry races from overwriting a title. |
listConversationspackages/shared/src/db.ts | Optional client id | Recent-first summary rows | Lateral joins return last non-tool message and newest unresolved user job in one query. |
insertUserMessagepackages/shared/src/db.ts | Conversation id, text | Pending user row | Gateway calls this before Redis publish. The row is the worker job. |
getMessage / getReply / getMessagesByReplyTopackages/shared/src/db.ts | Message/user-turn id | One row, final reply, or ordered turn rows | SSE replay and crash reconciliation distinguish final text from intermediate tool-request rows. |
getConversationHistorypackages/shared/src/db.ts | Conversation id | Deterministically ordered raw rows | Parent timestamp + role ordering keeps each user turn and its replies adjacent. |
sweepExhaustedLeasespackages/shared/src/db.ts | Maximum attempts | Expired rows changed to failed | claimAndProcess calls it before claiming so crash loops terminate. |
claimPendingMessagespackages/shared/src/db.ts | Worker id, lease duration, limit | Owned processing rows | Uses SKIP LOCKED, expired-lease reclaim, conversation advisory lock, and live-turn exclusion. Production caller passes 1. |
renewLease / stillOwnsLeasepackages/shared/src/db.ts | Message id and worker id | Boolean ownership result | Renewal updates only the current processing owner; checks gate tool/final persistence. |
markMessageDone / markMessageFailedpackages/shared/src/db.ts | Message id; bounded error text | Terminal job status | Both clear the lease; failure persists diagnosis across SSE loss/reload. |
requeueMessageForRetrypackages/shared/src/db.ts | User message id | Failed/expired row reset to pending | Refuses done/pending, non-user, or actively leased rows. Called by operator script. |
deleteConversationpackages/shared/src/db.ts | Conversation id, optional client id | deleted | not_found | in_progress | Transactional row locking refuses deletion while a live worker lease exists. |
insertAssistantMessagepackages/shared/src/db.ts | Conversation/turn ids, text, usage | Final assistant row | Unique reply index + conflict handling ensures one final row and returns the winner. |
insertToolCallRequestpackages/shared/src/db.ts | Turn id, iteration, calls | Assistant request row | Committed before tool execution; one request row per turn/iteration. |
insertToolResultpackages/shared/src/db.ts | Turn id, call id/name/result/error | Tool row | Committed after each execution; duplicate call ids no-op. |
insertCompaction / getLatestCompaction / getCompactionsForConversationpackages/shared/src/db.ts | Summary checkpoint or conversation id | Stored/read compaction metadata | Blank summaries are invalid; trigger id is an idempotency key. |
acquireFileLock / renewFileLock / releaseFileLockpackages/shared/src/db.ts | Canonical path, owner token, duration | Cross-process file lease | Owner-token comparisons prevent one caller releasing another’s lock. |
streamChannel / createRedisClient / createRedisSubscriberpackages/shared/src/redis.ts | Message id; REDIS_URL | Channel name and Redis connections | Separate subscriber connection is required by Redis Pub/Sub mode. |
| Function · source | Consumes | Output / effect | Relationship and invariant |
|---|---|---|---|
main / claimAndProcesspackages/worker/src/index.ts | Redis wake-ups and 1s timer | Sequential claimed-turn tasks | Prevents overlapping claim batches in one worker; sweeps then claims exactly one. |
processMessagepackages/worker/src/index.ts | Claimed user row | Recovery/direct/LLM branch; terminal status/event | Owns lifecycle, lease heartbeat, persistence hooks, error handling, and final ownership check. |
maybeGenerateTitlepackages/worker/src/index.ts | Completed user/assistant text | Optional conversation title | Runs before done; errors are non-fatal and a later turn retries. |
shutdownpackages/worker/src/index.ts | SIGTERM/SIGINT | Stop claims; drain; close clients/pool | After the default 20s drain, remaining work is left for lease expiry/reclaim. |
reconcileTurnStatepackages/worker/src/turn-recovery.ts | Tools, conversation/turn ids, user content | ready | already-final | already-final-direct | blocked-mutation | Retries only unresolved calls whose registered tool is explicitly read-only; unknown means unsafe. |
parseDirectInvocation / resolveDirectArgspackages/worker/src/direct-tool.ts | Raw /tool … text and schema | Invocation and argument object | Parser 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.ts | Owned row, bound tool, publisher | One request/result pair; empty-content done | No LLM follow-up or final assistant text row. Ownership is checked at every durable boundary. |
generateTitlepackages/worker/src/llm.ts | First available completed turn | Sanitized title ≤60 characters or null | Non-streaming model call; prompt limits each side to 2,000 characters. |
streamCompletionpackages/worker/src/llm.ts | Last 20 raw history rows, token callback | Assembled basic reply | Legacy/simple helper; production processMessage uses runToolLoop. |
getDefaultClient / buildRunToolspackages/worker/src/tool-loop.ts | Provider config; run lifecycle/deadline | LLM client and bound tool set | Subagent is rebound per run so child events and deadline are scoped correctly. |
executeToolCallpackages/worker/src/tool-loop.ts | Accumulated 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.ts | Iteration, call, hooks, lifecycle | Tool record + model tool message | Runs before/after hooks and emits execution/message events around exactly one call. |
isToolCallAssistantRowpackages/worker/src/tool-loop.ts | Database message row | Boolean classifier | Shared by history reconstruction and recovery to distinguish intermediate assistant rows. |
sliceHistoryAtTurnBoundariespackages/worker/src/tool-loop.ts | Raw history and row limit | Structurally valid history slice | Moves boundaries so tool request/result groups are never split. |
rowToChatMessage / lifecycleMessageToChatMessage / chatMessagesToLifecyclepackages/worker/src/tool-loop.ts | Persisted or hook messages | OpenAI/lifecycle message shapes | Preserves raw tool arguments and call ids required by compatible APIs. |
buildCompactedMessages / maybeSplitTurnpackages/worker/src/tool-loop.ts | History/current loop context and compaction options | Summary-overlay prompt | First handles historical turns; second handles an oversized still-open tool turn. |
runLoopBodypackages/worker/src/tool-loop.ts | History, tools, client, callbacks, hooks, persistence | Final content, ordered tool exchange, usage | Accumulates streamed calls by index; persists request before execution/result after; parallelizes only all-parallelSafe batches. |
buildUsage / abortedResultnested in runLoopBody | Round usage/timing and abort flag | Usage snapshot or stable fallback result | Context tokens describe the latest call, while prompt/completion totals accumulate across rounds. |
assistantLifecycleMessage / maybeEmitToolStartnested in runLoopBody | Current streamed text/calls | Observer snapshot and early tool signal | Tool start is emitted once when id/name become available, before full arguments may arrive. |
runToolLooppackages/worker/src/tool-loop.ts | History, stream callbacks, options | RunLoopResult | Emits turn lifecycle and races body against a 720s backstop (600s loop deadline + 120s headroom). |
Lifecycle.on / Lifecycle.emitpackages/worker/src/lifecycle.ts | Listener or typed event | Unsubscribe closure; ordered awaited notification | A throwing listener fails the emit and participates in turn settlement. |
createMessageSignalLogger / createLoggingHookspackages/worker/src/lifecycle.ts | Logger | Observational listener/hooks | Current production hooks log but do not patch, stop, or inject. |
| Function · source | Consumes | Output / effect | Relationship and invariant |
|---|---|---|---|
estimateTokens / estimateMessageTokens / estimateMessagesTokenspackages/shared/src/context-estimate.ts | Text or compactable messages | Conservative integer estimate | Counts message overhead and structured tool payloads; used when provider usage is absent. |
estimateHistoricalContextTokenspackages/shared/src/context-estimate.ts | History, reply id, latest compaction | Legacy reply context occupancy | Reconstructs retained prompt; never substitutes cumulative multi-round prompt usage. |
shouldCompactpackages/worker/src/compaction.ts | Prompt messages, threshold | Boolean | Default threshold is 40,000 estimated tokens. |
findTurnCutPointpackages/worker/src/compaction.ts | History, keep budget, prior boundary | User-row cut or null | Never cuts inside a completed turn or removes the newest user row. |
findAssistantCutPoint / findLastUserIndexpackages/worker/src/compaction.ts | Open-turn model messages | Safe assistant boundary | Never cuts at a tool row; locates only the active turn. |
applyCompactionpackages/worker/src/compaction.ts | Messages, summary message, cut index | Prompt overlay | Preserves system/existing-summary prefix and replaces only the selected span in memory. |
serializeRow(s) / serializeChatMessage(s)packages/worker/src/compaction.ts | DB or model messages | Bounded transcript text | Includes tool names/arguments/results so summaries retain operational facts. |
buildSummarizePrompt / buildTurnPrefixPromptpackages/worker/src/compaction.ts | Rows or open-turn prefix; optional prior summary | Summarizer user prompt | Prompts request factual state, decisions, errors, paths, and unresolved work. |
runSummarization / summarize / summarizeTurnPrefixpackages/worker/src/compaction.ts | Summarization client and prompt | Trimmed summary plus usage | Empty/blank output is a failed compaction; caller continues with uncompacted context. |
| Function · source | Consumes | Output / effect | Relationship and invariant |
|---|---|---|---|
toolEnabled / enabledToolManifests / manifestToJsonSchemapackages/shared/src/tools.ts | Manifest name/environment | Enabled UI list and model schema | TOOL_MANIFESTS is the shared source of name/arguments/read-only metadata. |
DEFAULT_TOOLS / DEFAULT_SUBAGENT_TOOLpackages/worker/src/tools/index.ts | Environment gates | Worker registry | Datetime, calculator, and subagent are always enabled; bash/file tools are opt-in. |
tokenizeExpression / ExpressionParser / evaluateExpressionpackages/worker/src/tools/calculator.ts | Arithmetic expression | Finite JavaScript number or validation error | Recursive descent supports + − × ÷ ^, parentheses, unary signs, and sqrt; never uses eval. |
getCurrentDatetimeTool.executepackages/worker/src/tools/datetime.ts | Optional IANA timezone | Formatted current date/time | Read-only; invalid timezone becomes a normalized tool error. |
buildBashExecArgs / runBash / createBashToolpackages/worker/src/tools/bash.ts | Shell script; optional spawn/timeout | Combined output or error | Runs 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.ts | Raw path and allowed root | Validated real/absolute path | Rejects literal .. and symlink escape from /home/ubuntu; this is path containment, not an OS sandbox. |
canonicalLockKeypackages/worker/src/tools/file-path-jail.ts | Validated target path | Stable real-path identity | Existing and new paths converge on the same cross-process lock key. |
truncateHead / createReadFileToolpackages/worker/src/tools/read-file.ts | Path, optional 1-based offset/limit | Annotated UTF-8 excerpt | Caps output at 200 lines or 4,000 characters and gives a continuation offset. |
writeFileAtomicpackages/worker/src/tools/atomic-write.ts | Target path and full content | Sibling temp file renamed over target | Deletes temp file if rename fails; rename avoids partial in-place content. |
createPostgresFileLock / withFileLockpackages/worker/src/tools/file-lock.ts | Canonical key, async mutation | Bounded exclusive lease around mutation | Backoff 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.ts | Path, full content, optional expected hash | Atomic create/overwrite | Lock covers read/compare/write. Expected hash rejects stale overwrite. |
countOccurrences / createEditFileToolpackages/worker/src/tools/edit-file.ts | Path, exact old/new strings | One atomic replacement plus diff | Requires exactly one match after acquiring lock and re-reading; empty/multiple/missing matches fail. |
createSubagentTool / resolveChildToolspackages/worker/src/tools/subagent.ts | Self-contained task/context, optional tool names | Nested runToolLoop final text | Child sees no parent history; requested names form an allowlist; default excludes subagent itself. |
createForwardingListener / formatSubagentResultpackages/worker/src/tools/subagent.ts | Child lifecycle/result | Tagged parent events and self-contained result | Run id/depth scopes events; sibling subagent calls are marked parallelSafe. |
| Function · source | Consumes | Output / effect | Relationship and invariant |
|---|---|---|---|
Appweb/src/App.tsx | Auth, storage, API, reducer state | Complete chat application | Selected conversation is separate from the per-conversation data/stream store. |
dispatchSyncweb/src/App.tsx | Reducer action | Immediate ref update + React dispatch | Closes the rapid double-submit gap caused by waiting for a render. |
beginStreamweb/src/App.tsx | Conversation/turn ids and stream URL | EventSource wired to reducer | Works for selected or background chats; estimates live tokens/sec from accumulated text. |
loadConversation / ensureLoadedweb/src/App.tsx | Conversation id | History state and possible stream attachment | Deduplicates initial loads; turns permanent failure into an assistant-shaped diagnostic. |
ensureOutstandingAttached / refreshConversationsweb/src/App.tsx | Sidebar outstanding-turn metadata | Repaired background streams | Checks attachment to the specific outstanding message, not merely loaded history. |
init / handleLoginSuccess / handleLogoutweb/src/App.tsx | Auth and saved selection | Restored/created session or clean reset | Logout closes all stream handles before reducer reset. |
handleSelectConversation / handleNewChat / handleDeleteConversationweb/src/App.tsx | User navigation actions | Selection and conversation mutations | Deletion chooses a surviving conversation or creates a new one. |
handleSendweb/src/App.tsx | Text and selected conversation | Optimistic user/assistant rows + stream | Blocks only the same conversation using synchronous submitting then confirmed sendingMessageId. |
isServerSentErrorEvent / streamReplyweb/src/lib/api.ts | EventSource events and handlers | Token/tool/done/error callbacks; closer | Distinguishes server event:error from native connection failure; native reconnect remains enabled. |
appendTextStep / estimateVisibleTokens / findOutstandingUserMessageweb/src/lib/conversationStore.ts | Stream steps, text, history | Coalesced steps, estimate, unresolved row | Outstanding detection uses user-row status, so an intermediate assistant tool row does not hide active work. |
conversationsReducerweb/src/lib/conversationStore.ts | Per-conversation actions | Immutable ConversationsState | Isolates concurrent streams/errors and clears stream handles on terminal events/removal/reset. |
matchTools / buildArgsJson / buildCommand / resolveFreehandweb/src/lib/slashTools.ts | Typed slash token/form values/manifests | Ranked tools and literal command text | Multi-argument calls require explicit form; quick freehand path is limited to read-only tools by Composer. |
getOrCreateClientId / conversation and sidebar helpersweb/src/lib/storage.ts | Browser localStorage | Stable random client id and preferences | Convenience scoping only; clearing storage creates another client identity. |
useMediaQueryweb/src/lib/useMediaQuery.ts | Media query string | Reactive boolean | Drives sidebar drawer/collapse behavior. |
Composer + input/argument handlersweb/src/components/Composer.tsx | Text, manifests, disabled state | Normal text or slash command | Keyboard picker and arg form reset after accepted submit; missing required args are allowed to become tool errors. |
groupSteps / MessageBubbleweb/src/components/MessageBubble.tsx | Message and live ordered steps | Markdown/text/tool bubble groups | Preserves text → tool → text ordering during a live turn. |
ToolCalls + toggleweb/src/components/ToolCalls.tsx | Tool summaries and turn id | Status chips and lazy details | Completed results are fetched once into component cache; running calls cannot expand. |
computeContextStats / formatContextLabel / StatsBarweb/src/components/StatsBar.tsx | Rendered messages and sending state | Context occupancy and live/average speed | Uses latest contextTokens against a hand-synchronized 270k capacity, not cumulative usage. |
Sidebar + pointer handlersweb/src/components/Sidebar.tsx | Conversation summaries and callbacks | Selection, status, deletion UI | Touch deletion requires a 500ms long press with movement tolerance. |
MessageListweb/src/components/MessageList.tsx | Messages, compactions, loading | Ordered transcript and automatic scroll | Threads conversation id to tool detail rendering. |
relativeDate / CompactionNotesweb/src/components/CompactionNotes.tsx | Newest-first compaction metadata | Chronological checkpoint notes | Presentation only; does not change message history. |
LoginScreenweb/src/components/LoginScreen.tsx | Password input | Authenticated transition or visible error | Uses cookie-based login wrapper; password is not stored client-side. |
6 · Tools
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
TOOL_MANIFESTS owns names, descriptions, arguments, and readOnly. manifestToJsonSchema converts that contract to model JSON Schema, keeping gateway, composer, and worker definitions aligned.
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.
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.
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).
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.
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 | Purpose | Enablement | Effect |
|---|---|---|---|
get_current_datetime | Read and optionally localize the current instant. | Always | Read-only |
calculator | Evaluate a bounded arithmetic grammar. | Always | Read-only |
subagent | Run a clean-context nested model/tool loop. | Always | Potentially mutating |
read_file | Read a bounded UTF-8 file slice. | TOOL_READ_FILE_ENABLED=true | Read-only, sensitive |
write_file | Atomically replace a file. | TOOL_WRITE_FILE_ENABLED=true | Mutating |
edit_file | Replace one exact string. | TOOL_EDIT_FILE_ENABLED=true | Mutating |
bash | Run Bash as the host ubuntu account. | TOOL_BASH_ENABLED=true | Unrestricted |
get_current_datetimeReturns one current timestamp. Optional timezone names an IANA zone such as Europe/London. No non-empty string means UTC ISO 8601.
Returns a string. Invalid zones become a targeted tool error. A recovery retry is safe, but naturally observes a later instant.
Date so date and time agree.toISOString().Intl.DateTimeFormat with full date, long time, and the requested zone.Invalid timezone.Source: packages/worker/src/tools/datetime.ts
calculatorEvaluates non-empty expression text without executing JavaScript. Grammar: numbers, whitespace, + - * / ^, unary signs, parentheses, and sqrt(...).
Returns the number as a string. It rejects unknown characters/functions, malformed numbers, missing parentheses, trailing tokens, division by zero, and negative square roots.
tokenizeExpression scans numbers, names, operators, parentheses, then appends EOF.ExpressionParser descends through expression, term, power, unary, and primary precedence.2^3^2 right-associative.sqrt.Source: packages/worker/src/tools/calculator.ts
subagentRuns a focused child task through the same loop and returns its final answer. task is required; context and requested tools are optional.
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.
runToolLoop recursively while suppressing child token streaming.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
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_fileReturns 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.
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.
/repo, call realpath, and verify containment.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_fileReplaces a file with complete content, creating parents. Optional expected_hash is SHA-256 of existing UTF-8 text.
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.
expected_hash with sha256Hex(existing).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_fileReplaces one exact occurrence and returns a compact line diff. Requires path, non-empty old_string, and new_string.
No hash argument. Protection comes from a fresh read after locking plus the exactly-one-match rule. Zero or multiple matches fail.
indexOf.-/+ lines.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
bashRuns 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.
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.
sudo with fixed arguments and piped standard streams.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
| Property | Model-selected | Direct slash |
|---|---|---|
| Selection | Model chooses enabled schemas with tool_choice=auto. | User names a registered tool. |
| Arguments | Streamed fragments accumulate by call index. | /name {json}, or plain suffix for one argument. |
| After result | Append to model context; model may call again or answer. | Mark done with empty assistant content and no LLM usage. |
| Durability | Both persist request before execution and result afterward, with identical lifecycle events. | |
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
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.
.envOPENAI_API_KEY=…
AUTH_PASSWORD=…
AUTH_SECRET=…
# optional:
OPENAI_BASE_URL=https://api.openai.com
OPENAI_MODEL=gpt-4o-mininpm install
npm run infra:upRuns docker-compose up -d, then waits up to 60s for PostgreSQL :5433 and Redis :6380.
npm run dev
# optional additional worker:
npm run dev:workerGateway listens on :3000. Each worker processes one turn at a time.
cd web
npm install
npm run devCurrent Vite config binds 127.0.0.1:4827 and proxies /api to :3000. web/README.md still says 5173 and is stale.
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
test. Create chat_test once, then use npm run test:db -w packages/worker. Those tests truncate shared tables.| Symptom | Inspect | Likely boundary | Next action |
|---|---|---|---|
| POST succeeds; no worker activity | User row status; worker subscription/log; Redis health | Wake-up may be missed, but poll should find row | Wait one poll; confirm worker logs and DB connectivity. |
Row stays processing | worker_id, lease_expires_at, attempt_count | Worker stalled/crashed or lease renewals failing | Do not requeue a live lease. Let expiry/reclaim or diagnose worker. |
Turn is failed | last_error; worker log prefix msg=<id> | Provider/tool/persistence/recovery failure | Fix cause, then run npx tsx scripts/requeue-message.ts <messageId>. |
| Failure mentions unresolved mutating call | Turn rows and external target (file/command effect) | Crash after possible effect but before result commit | Verify reality, record it with resolve-tool-call.ts, then requeue. |
| SSE closes with server error | Named error event and persisted last_error | Worker terminal failure | Use message id to correlate gateway/worker/database. |
| SSE reconnects but partial text is missing after reload | Final reply row and active status | Redis tokens are transient; partial assistant text is not checkpointed | Wait for completion, then reload history. See documented reconnect limitation. |
| Conversation delete returns 409 | Live processing lease | Intentional delete/worker race guard | Wait for done/failed/expiry, then retry. |
| File mutation times out | file_locks row and tool error | Another worker holds the canonical path lease | Wait/retry; re-read before applying stale edits. |
| Context number looks large | context_tokens vs cumulative prompt_tokens | Usage semantics | UI must display latest-call context, not multi-round sum. |
last_error and locate the unresolved toolCallId.npx tsx scripts/resolve-tool-call.ts <messageId> <toolCallId> "<result>" [--error].npx tsx scripts/requeue-message.ts <messageId>.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
Correctness guarantees are strongest inside PostgreSQL. Authentication, browser identity, and host tools are intentionally lightweight or trusted.
| Boundary | Code-enforced | Trusted / advisory | Not guaranteed |
|---|---|---|---|
| Turn ownership | Atomic claim, lease owner checks, unique reply/tool indexes, max attempts | Workers use claim limit 1 and keep heartbeat responsive | Cancellation/fencing of a tool syscall already running |
| Ordering | Conversation advisory lock and live-turn exclusion | Current single-turn worker loop | Correctness if claim limit is raised without redesign |
| Delivery | Persist-before-publish; subscribe-before-query; final reply replay | Redis availability for smooth live progress | Replay of every token or partial assistant text from Redis |
| Authentication | Signed expiring cookie; shared password; timing-safe checks; Secure/HTTP-only/SameSite | One trusted user population and protected network edge | User accounts, roles, distributed rate limiting, revocation list, CSRF tokens |
| Client scoping | Conversation listing/deletion can filter by local clientId | Browser-generated id remains honest | clientId is not an authorization boundary; authenticated callers can choose it, and direct history/tool routes do not apply ownership checks. |
| File tools | Literal traversal/symlink containment under /home/ubuntu, canonical locks, exact edit match, optional expected hash, atomic rename | The model/operator requests appropriate paths and changes | Credential secrecy inside the allowed root; filesystem-level fencing after lease loss |
| Bash | Non-empty command, 20s promise timeout, 4,000-char returned-output cap | Trusted operator intent | Sandboxing. It runs as ubuntu, whose documented host has passwordless sudo and Docker access; timeout does not explicitly kill the spawned child. |
| Compaction | Raw rows preserved; non-blank constraint; idempotent trigger; safe cut points | Model summary accurately preserves old facts | Lossless semantic summarization |
| Provider | Timeout backstop, malformed-call normalization, usage fallback | OpenAI-compatible streaming/tool-call behavior | Availability, deterministic output, or exact usage reporting |
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.
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.
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.
/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
Keep shared contracts authoritative, add durable state before presentation, and extend each cross-layer event end to end.
| Change | Implementation path | Required checks |
|---|---|---|
| Add a tool | Define 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 route | Add 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 event | Extend 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/entity | Add 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 policy | Prefer 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 feature | Put 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 strategy | Update 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. |
Source notes
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.