Skip to content

Experimental AI Assistant Backend

The AI assistant is a separate FastAPI application that answers text questions about one schedule snapshot. Its ASGI entry point is nurse_scheduling.ai_serve:app. It does not import the optimization server or use its Redis data.

File attachments are enabled by default. The frontend accepts arbitrary file types and copies them into the disposable sandbox without executing them. The assistant reads and edits the schedule in that sandbox. When a run finishes with a valid changed schedule, the backend adopts it and the browser applies it. Each run owns a workspace. It creates an E2B Cloud sandbox on the first workspace tool call. This version excludes retrieval and repository access.

Run locally

See the core instructions for this task.

Architecture

Foreground questions and optimizer-result reviews use the same AgentSession execution path. The HTTP routes authorize and validate requests before handing them to the session. The model loop receives executable tool contracts with their concurrency policy.

Component Responsibility
app.py HTTP routes, authentication, and application startup and shutdown.
agent_session.py Conversation changes, steering, applied schedules, and the shared run path.
sessions.py, lifecycle.py SessionStore access and budgets, plus SessionRuns ownership through cleanup and persistence.
agent.py, agent_loop.py, agent_types.py Observable execution state, the model loop, and executable tool contracts.
workspace.py, workspace_tools.py Lazy sandbox allocation and hydration, tool binding, and trusted schedule review.
transcript.py, context.py Typed conversation entries, provider requests, history selection, and application event presentation.
optimizer.py, optimizer_tool.py, optimizer_http.py Job ownership and operations, model-facing arguments and replies, and HTTP transport.
session_events.py, session_event_projection.py Typed public events, tool call identity, text batching, and terminal publication after finalization.
recovery.py, history.py, session_event_stream.py Ordered persistence, bounded session replay, and restart recovery.

Figure 1. Backend execution and resource owners.

flowchart TB
    Browser["<b>Browser</b><br/>Questions and applied schedules"]
    Routes["<b>app.py</b><br/>Authorize HTTP requests"]
    Runs["<b>SessionRuns</b><br/>Own accepted runs"]
    Store["<b>SessionStore</b><br/>Access, expiry, and budgets"]
    Session["<b>AgentSession</b><br/>Own conversation changes"]
    Agent["<b>Agent and agent_loop</b><br/>Execute model and tool contracts"]
    Workspace["<b>Workspace</b><br/>Create E2B sandbox on first use"]
    Optimizer["<b>Optimizer operations</b><br/>Own independent API jobs"]
    Browser -->|Requests| Routes
    Routes -->|Start or stop| Runs
    Routes -->|Access session| Store
    Runs -->|Execute| Session
    Store -->|Retain| Session
    Session -->|Prepare and consume| Agent
    Agent -->|Workspace tools| Workspace
    Agent -->|Optimizer tools| Optimizer

Run completion

Foreground questions and optimizer-result reviews use the same owner. A foreground request is rejected while another run owns the session. Background reviews wait in queue order. A disconnected browser does not cancel that owner.

Figure 2. Run output, cleanup, persistence, and terminal delivery.

flowchart TB
    Accept["<b>Accepted request</b><br/>message_id identifies one run"]
    Execute["<b>Server-owned execution</b><br/>Publish provisional run output"]
    Cleanup["<b>Cleanup</b><br/>Close model generators<br/>Destroy any allocated sandbox"]
    Commit["<b>Session finalization</b><br/>Commit or abort the RunSnapshot"]
    Recovery["<b>Recovery finalization</b><br/>Save outcome and context<br/>Report storage failure if needed"]
    Terminal["<b>Terminal delivery</b><br/>Publish done, stopped, stale, or error"]
    Release["<b>Release run owner</b><br/>Allow the next queued run"]
    Accept -->|JSON acknowledgement and SSE<br/>can arrive in either order| Execute
    Execute -->|Complete, Stop, or fail| Cleanup
    Cleanup -->|Fix the outcome| Commit
    Commit -->|Await final writes| Recovery
    Recovery -->|Flush held terminal event| Terminal
    Terminal -->|Finish execution| Release

AgentRun.cancelled records a cancellation request. finishing prevents a late Stop from changing a result after cleanup has fixed its outcome. These flags and queue ownership are separate from the browser's running, stopping, and interrupted phases. RunEvents holds terminal events until finalization finishes, including when recovery storage reports a failure.

Frontend controller

The page owns credentials, composer files, speech, scrolling, and browser download URLs. useAiChat owns chat state, saved conversation restoration, message submission, steering, session expiry, and applied schedules. ChatTranscriptView renders that state through explicit props and callbacks.

Figure 3. Frontend state, connection ownership, and rendering.

flowchart TB
    Page["<b>page.tsx</b><br/>Browser controls<br/>and schedule import"]
    Controller["<b>useAiChat</b><br/>State and actions<br/>Router and event helpers"]
    Reader["<b>Connection hook</b><br/>useSessionEventStream<br/>Reader, cursor, and retry"]
    Transcript["<b>ChatTranscriptView</b><br/>Messages and controls"]
    Saved["<b>Session storage</b><br/>Conversation and<br/>pending request"]
    Page -->|Options and callbacks| Controller
    Controller -->|Own connection| Reader
    Controller -->|State and actions| Transcript
    Controller -->|Restore and persist| Saved

The reader hook revokes callbacks from a closed connection and owns its retry timer. Accepted deferred events remain scoped to the conversation. A cursor reconnect continues the active reply after steering. A replacement snapshot rebuilds the run from its original reply and preserves surrounding message positions. ChatLifecycle owns browser operation phases and busy state.

Session and model context

Figure 4. Retained conversation and the next provider request.

flowchart TB
    Transcript["<b>Typed transcript</b><br/>Questions, answers, app events, and decisions"]
    Context["<b>context.py</b><br/>Project history and interruption notes"]
    Current["<b>Current run</b><br/>Model responses and tool results"]
    Prompt["<b>Prompt inputs</b><br/>Fixed instructions and schedule summary"]
    Request["<b>Provider request</b><br/>Initial layout and follow-up messages"]
    Provider["<b>Provider</b><br/>Complete one model request"]
    Transcript -->|Retained entries| Context
    Context -->|Selected history| Request
    Current -->|Follow-up messages| Request
    Prompt -->|Instructions and request values| Request
    Request -->|OpenAI-compatible messages| Provider

build_provider_messages builds the initial layout. The model loop calls prepare_provider_request for each follow-up, combining that layout with current run entries. Session retention, model context selection, and event replay have separate limits.

The browser receives an HTTP-only owner cookie and an unguessable session UUID. Active sessions expire after 30 days of inactivity by default. Sending or queueing a message or synchronizing a changed schedule renews that window. The browser can retain the conversation within its current tab and verify the session without extending its lifetime. The session records typed questions, assistant responses, app events, reasoning metadata, and tool exchanges. Tool images are released when a run ends. Stop and failed runs retain their questions with an interruption note. Later model context omits the incomplete response, retains completed exchanges, and explains that earlier tool results describe the discarded workspace. Recovery stores typed entries, including their origin and interruption state. Existing text history remains readable when the service restores older sessions. New questions receive a JSON acknowledgement with their run_id. Foreground and optimizer output use the same session GET event stream. Repeated requests with the same message_id return the accepted run without executing the question again. The browser buffers events received before acknowledgement and routes them by run_id. A disconnected reader reconnects without cancelling server work. Expired cursors receive a replacement session_reset snapshot. Replay has separate event-count and serialized-byte limits for each session and for the process. When an earlier snapshot cannot prove an acknowledged run is active or complete, the browser requests GET /sessions/{session_id}/events?reset=true. The fresh snapshot resolves expiry without rejecting a run accepted after the earlier reset. Replacement replay keeps answers beside their original questions. The service applies queued steering before each follow-up model request, including requests after refused tool calls. It requires provider completion before tools run and rejects conflicting finish reasons or further output after completion.

The backend stores the YAML snapshot and retained conversation turns. Each provider request includes a schedule summary, recent history, and the current question. The complete YAML stays in the sandbox until the model reads relevant content through a tool. With PostgreSQL, uploaded source files survive restarts and idle session eviction until the session expires. Removing a file detaches it from future turns but preserves its archive. Without PostgreSQL, they remain in process memory only. Conversation context keeps filenames and attachment markers, while recovery entries can retain inspected document content and tool output. The prompt gives the agent workspace paths for schedules and attachments.

Persisted uploads keep only metadata in live sessions and run snapshots. Recovery and text-only responses do not read their bytes. Sandbox hydration loads and delivers one file at a time, which limits attachment buffering to one file per active sandbox. This adds a storage read and sandbox write per file. Upload requests can still buffer files within the configured request limits. Failed saves retain unsaved bytes for retry and count them against the memory budget until a later state save succeeds. Generated ZIPs and optimizer workbooks, original schedules, backend input YAML, inspection context, and result metadata use the same session archive. Saved downloads and optimizer workbooks are loaded on demand after recovery.

Optimizer jobs and result reviews

The background status box has a Cancel optimizer control. It posts to /sessions/{id}/optimizations/{job_id}/cancel and uses the AI service's existing optimizer transport. The public job ID prevents a delayed click from cancelling a newer job. Authentication and browser session ownership still apply. State updates and the cancellation summary arrive on the session stream. Cancellation uses no model call or sandbox, and cancelled jobs do not start a result review. It leaves an active chat response running.

Finish now posts to /sessions/{id}/optimizations/{job_id}/finish-now through that same transport. The agent uses optimizer(action="finish_now"). Both paths record finish_now_requested_by as user or agent for the result review and chat archive. The solver's termination_reason remains authoritative, including when it finishes before the control request returns. A failed request allows a retry. Repeated requests do not resubmit an accepted control.

Status events retain backend creation, start, and finish timestamps. The widget updates elapsed running time locally each second and shows waiting time before a job starts. Completion events, agent reviews, and exports include elapsed_seconds, measured from the backend start to finish timestamps.

Figure 5. Independent optimization and serialized result review.

flowchart TB
    Tool["<b>Optimizer tool</b><br/>Submit sandbox working YAML"]
    Jobs["<b>Optimizer API</b><br/>Execute an independent job"]
    Monitor["<b>AI optimizer monitor</b><br/>Watch status and collect the result"]
    Cache["<b>Result cache</b><br/>Bound workbook bytes and restore person IDs"]
    Runs["<b>SessionRuns</b><br/>Queue a background review"]
    Review["<b>AgentSession</b><br/>Review result metadata with the model"]
    Workspace["<b>Next workspace</b><br/>Hydrate retained workbook on first tool use"]
    Tool -->|Normalized input and server credential| Jobs
    Jobs -->|Status and completed workbook| Monitor
    Monitor -->|Retain result and delete remote job| Cache
    Monitor -->|Enqueue result metadata| Runs
    Runs -->|Execute when the session is idle| Review
    Cache -->|Make workbook available| Workspace
    Review -->|Workspace tool call| Workspace

The server-side optimizer tool submits a copy of the current sandbox working YAML to the existing optimizer API. It replaces person IDs and removes descriptions as the browser's Optimize and Export flow does, while retaining the reverse ID mapping server-side. It returns immediately and keeps the remote credential and job ID outside the sandbox. A process-local monitor waits for terminal status, restores person IDs in the output workbook, retains the size-bounded workbook for an authenticated browser download, deletes the remote optimizer job, and starts a new assistant turn with result metadata. The restored workbook is copied to /workspace/optimizer-results/optimized-schedule.xlsx in that turn and later chat turns while retained. It is separate from user attachments. The browser keeps one replayable session stream open for foreground answers, optimizer status, and background reviews. The event journal and recovery projection each retain up to 1,000 required events, with a separate limit of 100 transient progress updates. Each list's serialized size is limited to 4 MiB per session. Together, the lists are limited to 64 MiB across the process. Expired cursors receive a replacement snapshot. PostgreSQL stores accepted questions before execution and saves non-progress publications through an asynchronous writer. It queues completed model entries before tool execution. Consecutive text fragments can share a storage row. The browser reattaches with the same client message ID and replaces partial output with the snapshot. It does not start another model run. Foreground chat and optimization can proceed at the same time. Assistant turns remain serialized per session. The Stop control cancels either a foreground or background assistant turn and waits for the server outcome. It names the client message ID so a question stopped before arrival cannot start later. After a connection failure, the browser sends the Stop again before it reconnects. A delayed Stop for an earlier message does not cancel a later turn. A Stop without a message ID cancels every active assistant turn in the session. No Stop cancels the independent optimizer run.

Sandbox and conversation state are separate. The backend copies the current schedule to /workspace/schedule.yaml and searchable schema documentation to /reference. It writes uploads below /workspace/attachments under safe paths prefixed with their upload IDs. The backend then runs every command for that user message in the same sandbox, reads the candidate, and destroys the sandbox. A later run creates a new sandbox when it calls a workspace tool. Only conversation history and the current schedule remain in application state.

When a turn fails, its provisional activity remains visible but is not added to model conversation history. Optional PostgreSQL logging retains failed turns for operators. The chat asks the user to send a new message and offers no retry button. Uploaded files stay available to that message. If an upload did not finish, the question was not sent, and the chat asks the user to attach the files again.

Provider session affinity

Each run binds AgentSession.id to a provider copy through with_session_id. The copy preserves the selected model, reasoning parameters, credentials, and provider metadata. Session IDs and request limits stay on each copy, so concurrent chats cannot overwrite each other's settings. Direct-provider copies share cached model-limit discovery. A failed discovery request is not cached, so the next request retries it. Optional session binding also keeps existing providers without this capability usable.

The Chat Completions adapter sends x-session-id on every completion request. Tool follow-ups, timeout retries, compaction summaries, and optimizer reviews use the same bound provider. Later runs and restored sessions use the same chat UUID. Each billable attempt still has its own ProviderCall.id.

This follows Pi v1.0.0's provider session options, using its x-session-id header format for the LiteLLM gateway. Enable session_affinity in the gateway's router_settings.optional_pre_call_checks. A live routing check can compare x-litellm-model-id response headers for successive requests, as described in the gateway example. The header does not change the prompt or add model-specific request parameters.

Provider retries

The adapter follows Pi v1.0.0's HTTP retry policy and account-limit exclusions. One shared attempt budget covers HTTP 408, 409, 429, server errors, timeouts, connection failures, and streams that end without completion. Provider retry hints refine transient status handling. Authentication, malformed requests, context overflow, account quota, and billing errors fail immediately, even with a retry hint. Invalid stream data and unsuccessful finish reasons also fail immediately.

AI_PROVIDER_MAX_ATTEMPTS includes the first request. Exponential backoff uses AI_PROVIDER_RETRY_BACKOFF_SECONDS, an eight-second cap, and up to 25 percent jitter. Valid Retry-After-ms or Retry-After headers take precedence, including HTTP dates. A server delay above 60 seconds fails immediately. Retries also stop 120 seconds after the first failure, because a waiting run holds a shared run slot and its sandbox. Each retry adds a chat notice with its delay and attempt number. Stop interrupts backoff and streaming. Each attempt keeps its own cost record and the same session ID. The adapter uses one retry budget instead of stacking SDK retries with whole-agent retries.

After interrupted output, the adapter retries the original request. Failed attempts stay in the audit transcript but are omitted from model requests. The frontend labels their partial text and reasoning as omitted from future context. Failed reasoning metadata and partial tool arguments are discarded. Tools are released only after a clean response completion. A retry after a completed tool exchange resends its result without executing it again. Summary generation also discards the failed draft before collecting the retry, matching Pi's completeSummarization. Each attempt retains its cost record, and the provider's defensive HTTP byte limit applies separately to each attempt.

Automatic context compaction

POST /sessions/{id}/compact accepts a client message_id and starts an idle, server-owned compaction run. It shares run admission, Stop, replay, and cost accounting with chat runs. It uses the session's selected model and reasoning settings. It saves no user question and generates no normal answer or workspace. Repeated IDs return the same run. Summary events use the manual reason. Migration 010 adds the compaction run kind. Failed and interrupted manual runs keep the original context without a workspace-discard notice.

generation_progress events report draft character counts for compaction and tool-call arguments. Updates are coalesced with the stream's 25 ms batching. Compaction identifies each summary pass and resets its count on retry. Draft summary text and incomplete tool arguments stay private. Chat and exports count Unicode code points and show tool-result size after execution. These character counts are separate from provider-reported token usage and costs.

ContextCompactor follows Pi v1.0.0 compaction. Automatic compaction starts when context tokens exceed the model window minus AI_COMPACTION_RESERVE_TOKENS (default 16,384). Character and message retention budgets do not trigger compaction. Model metadata must report a positive context window. A missing window rejects generation instead of assuming a fallback size.

Manual and automatic compaction use Pi's same cut-point algorithm with AI_COMPACTION_KEEP_RECENT_TOKENS (default 20,000). The model window and server history budgets do not reduce that retention target. Cut points preserve tool calls with their results. A short conversation stays unchanged when no older messages remain to summarize. If the cut splits a turn, older history and the turn prefix receive separate summaries. A completed summary is accepted without a character-count reduction requirement. Failed summaries keep original context.

The estimate uses the most recent completed same-model input and output usage, plus estimates for later messages. A characters/4 estimate covers missing usage, changed model settings, tool definitions, and current system and status content. Old usage is invalid after a summary replaces its original input. Cached tokens are already included in Chat Completions prompt usage.

The composer footer derives Context percentage and Tokens from the same backend token count and model context limit. It prefixes estimates with ~ and shows an unavailable percentage when the model limit is unknown. Character and message limits remain internal retention safeguards. Estimated cost is separate. AgentContextUsage estimates include the current request and streamed reasoning or answer text. RunEvents coalesces these reports with text batches. Provider usage replaces token estimates, and provider cost updates replace each call's previous cost contribution. Estimates do not enter billed usage or invent missing prices.

Replayable compaction_start and compaction_end events report manual, threshold, or overflow recovery, success, and cancellation. The live transcript and saved exports show the same status. Successful compaction replaces the context meter with a fresh estimate until the next model call reports usage. Summary failures keep the original context. Stop closes the summary stream and finishes its status.

Older messages are serialized as conversation data. Tool arguments retain file paths. Tool results are limited to 2,000 characters per result, as in Pi. Opaque reasoning is never converted to text for summarization. The separate manifest ai/prompts/compaction.json records the initial, update, split-turn, and system prompts from Pi v1.0.0 at a13d35a. Each upstream string is unchanged. The manifest records matching upstream and model-input hashes. Project guidance about schedules and temporary sandbox files stays in the main system prompt. The prompt loader removes provenance comments before model requests.

A later compaction passes the existing summary separately in previous-summary tags and uses Pi's update instructions. When a cut splits an active turn, older history and the active turn prefix receive separate summaries. The latter uses Pi's dedicated checkpoint format, and recent tool exchanges stay intact. Recent responses retain their native replay metadata. As in Pi, each summary saves the read, written, and edited paths separately, appends them in read-files and modified-files tags, and carries them into later summaries.

Summary calls use the frozen run model and reasoning selection. Pi limits history summary output to 80 percent of the reserved tokens and turn-prefix output to 50 percent, both subject to the provider's model and remaining-context limits. There is no additional summary character limit or local input chunking. An oversized summary request fails without replacing the original context. The provider keeps its context safety margin and numeric thinking allowance.

Summary requests have no tools, and their text and reasoning do not enter the answer stream. Their provider call records include costs and usage. An empty, truncated, oversized, failed, or tool-requesting summary leaves the original context intact. Stop cancels summary generation through the existing run owner. A confirmed provider context overflow permits one compact-and-retry attempt before reporting failure. Tool execution is not repeated by that retry.

A successful summary appends a CompactionEntry, leaving earlier audit entries intact. Its completion event includes the exact summary and retained-message count. The frontend displays the summary separately and marks every summary from a failed run as discarded, including summaries before steering replies. Saved chat exports use the same rule. Context projection replays a valid summary plus its referenced recent tail. Retention keeps the summary and that tail together. Hard service memory limits can still remove an entire older context unit. Failed, cancelled, and stale runs cannot replay their summaries of discarded workspace work. Migration 009 adds the compaction entry type. Back up the database before upgrading, and restore the backup when rolling back to a version that cannot read that type.

Reasoning and tool activity

Providers stream visible reasoning through reasoning_content, reasoning, or reasoning_text. The adapter forwards it as a separate display event. Completed responses also retain the original native fields, OpenRouter reasoning_details, and LiteLLM thinking_blocks, including signatures and encrypted or redacted blocks. The gateway can wrap thinking blocks in provider_specific_fields. Tool calls retain native extra_content and provider_specific_fields metadata. These fields survive session commits and PostgreSQL recovery. Migration 008 converts older trim offsets to include tool results. Back up the database before upgrading. To roll back, restore that backup so older code reads its original trim offsets. Tool image bytes remain transient.

The provider boundary replays native metadata only when the endpoint, API, and configured model match its source. The first accepted message fixes the model for the conversation. The session model-selection response reports that saved lock as locked, so a resumed browser shows it. Reasoning effort can change for future runs. The provider boundary still omits incompatible native metadata and normalizes matching tool call IDs for historical or imported data. Reasoning never becomes assistant answer text. Historical unsigned reasoning without source metadata remains display-only. Retained reasoning can increase later input token usage.

This follows Pi v1.0.0 message transformation and its Chat Completions adapter. This service uses a Chat Completions gateway rather than native Anthropic or Responses transports. The gateway must support the replay fields it returns, as described in the LiteLLM reasoning contract. Unknown fields are not guessed from model names. Provider rejection remains a normal provider error rather than silently dropping signed state.

Aborted and errored responses are excluded from model replay. Completed responses and tool exchanges remain. A separate app event explains when the run workspace was discarded and warns that earlier tool results describe that workspace. Timeout retries restart only the incomplete model call from the same request. Restart recovery saves interrupted tool results and the context-omission notice in the same transaction as the interrupted entries. A final answer awaiting validation is marked failed, and the run's summaries are marked as not applied. Earlier completed exchanges remain. Missing tool results receive an explicit failure placeholder that is also shown in the frontend. Truncated calls expose their original arguments and the empty arguments used for model replay. Tool pairs are repaired before the next turn, so each replayed tool call has a result.

The tool_start event carries the tool name and arguments before execution, so the UI and evaluation artifact retain a command even if the sandbox fails. A later tool event carries its result and whether the call did what it was asked. The E2B command wrapper combines stdout and stderr as they arrive and writes the full log inside the sandbox. It retains a bounded UTF-8 tail and sends that tail with line and byte counts through the SDK. The bash adapter keeps the last 2,000 lines or 50 KiB and links the full log when truncated. Small logs are deleted after capture. A command timeout preserves partial logs when process cleanup succeeds. If the log is too large to read before the control deadline, the result has no output, and the sandbox remains available. An SDK timeout during that read can mean that the sandbox ended, so the service discards the sandbox. Logs live until the disposable sandbox closes. Command output has no separate capture ceiling. Sandbox disk capacity and execution deadlines bound resource use. A 1 MiB transport bound protects the small JSON report, independently of the log's size. File reads stream at most 64 MiB by default. Schedule reads use the configured schedule limit, and ZIP reads use the download limit.

When one model response requests multiple reads, the reads run concurrently and their results are returned in the model's original call order. Batches that contain bash, edit, or write remain sequential so filesystem mutations have deterministic ordering. E2B stays active for either kind of batch and pauses again before the next provider reasoning turn.

An agent run spans the model/tool loop until the agent finishes. Pi uses a turn for one model response and its tool results. VM lifecycle code uses run.

Agent runs default to a six-hour deadline (AI_SANDBOX_TURN_TIMEOUT_SECONDS=21600) with defensive defaults of 2,000 tool rounds and 6,000 tool calls. The deadline includes provider responses, sandbox operations and paused time. Each optimizer follow-up is a separate run with its own limits. Stop still cancels an active run.

Each run holds one AI_MAX_CONCURRENT_REQUESTS slot, four by default, from its start until it ends. A run does not hold a slot while its optimizer job solves. Other runs wait for a free slot, so four long runs can delay every other session for up to the run deadline. Autonomous optimization can also chain follow-up runs without a user message until the session reaches the defensive bound of 1,000 optimizer runs by default. Raise the concurrency limit when a deployment expects long runs, or lower the run deadline.

The VM active timeout is independent of the agent run deadline. It is one hour, which is the E2B Hobby tier maximum. Timeout pauses the VM with memory preserved. Each auto-resume restarts the one-hour countdown, and the application pauses the VM after each tool batch. A long run reaches the timeout only when the VM stays active for a full hour without a pause. If that happens because an explicit pause failed, the next file or command request auto-resumes the VM. The application sends no timeout refresh requests. A VM that the application loses, for example after a backend crash, stays active for at most one hour. Cleanup metadata keeps the full run deadline, and run completion or cancellation destroys the VM. See E2B timeout after auto-resume.

When a Bash command changes the schedule, the backend reads the working copy and validates it outside the sandbox before emitting schedule_change. The event contains that validated working copy. The browser compares it with the previous working copy and renders the changed lines in red and green. These intermediate previews do not change the session schedule. The final validated candidate becomes the session schedule when the run commits.

Recovery database upgrade

Deploy the matching client and server together for the session stream contract. Migration 004_unified_session_recovery.sql preserves the existing recovery sessions, owners, expiry, schedules, accepted message IDs, named Stop requests, and saved output. It converts independent foreground and background cursors into publication order. Restored streams replace old browser cursors. Typed snapshots preserve entry origin. Older text histories retain the information available in their saved format, which cannot distinguish user text identical to an application event.

Before upgrading, stop every old AI service instance and back up the PostgreSQL database. Follow the backup and restore runbook. Apply the migration through service initialization, then start the matching frontend. The migration runs in one transaction under the existing advisory lock. Verify restoration of a saved session and retry its accepted message ID before allowing traffic.

Migration 006_chat_uploads.sql adds upload storage without changing existing sessions. Files lost before this upgrade cannot be recovered. Database backups now include original uploads. Rolling back to code before this migration loses attachment references when that code next saves a session. Keep a backup of the upgraded database before a rollback.

Migration 007_session_files.sql preserves existing upload bytes and metadata in chat_files. Removal records a timestamp instead of deleting a file. All file types use the session expiry and cascade deletion. Older code expects the previous table layout, so a rollback requires the matching database backup.

Reverting code alone cannot restore the old table layout. To roll back, stop the new AI instances, restore the backup into an isolated database, and deploy the old server and frontend against it. The backup does not include conversations written after the upgrade. Keep the upgraded database until those records are no longer needed.

Evaluation

See the core instructions for this task.

When a request supplies conflicting staffing totals and component counts, the assistant asks which counts to keep before reading guides or the current schedule. It proceeds directly when the user explicitly replaces the old counts.

The staffing-conflicting-day-total case checks clarification followed by the user's chosen total. Both prompt versions passed 3/3. The added clause reduced mean whole-conversation total tokens from 134,130 to 77,605, meeting the declared 20% reduction target. The staffing-explicit-day-total-override control passed 5/5 in both versions and used 4.7% more tokens with the clause. The equally weighted case means decreased by 24%. These selected checks used unsloth/Qwen3.8-27B-NVFP4, real E2B commands and four case jobs. They did not submit optimizer jobs or establish a general cost or latency saving.

Workbook imports must retain the required at most one shift per day rule, including imports that exclude optional staffing or rest rules. The import guidance asks the agent to derive request weights from each cell's formatting and build the proposal from the source in code. The workbook-import-first-valid-proposal case improved from 0/3 to 3/3 against the previous full prompt. Explained and unexplained workbook controls passed 3/3 in both versions. These checks used real provider requests and E2B commands with four case jobs and did not submit optimizer jobs. Some runs still used handwritten YAML, and one repaired generated-script errors. No cost saving or universal use of the requested coding method is claimed.

A successful optimizer start ends the assistant turn. The server skips later calls in the same batch and sends a fixed running update. An earlier startup acknowledgement told the assistant to stop tool calls and return that update. With the same full prompt, optimizer-authorized-policy-change improved from ⅘ to 5/5 against the acknowledgement before it. The forced end of turn made that instruction redundant, so the acknowledgement now reports only the job and wake-up behavior.

New-roster intake uses separate prompt steps for guide order, rest questions, fairness questions and optional primary shift groups. Each step states its own trigger and scope limits and has a separate evaluation witness. The combined new-schedule-policy-intake case also checks rest and fairness together. Declined-rule and explanation-only cases check that intake respects the user's scope.

Agent capabilities

After an accepted edit to /workspace/schedule.yaml, the server refreshes /workspace/schedule-context.json when the schedule can compile. Request inspectors can then read new candidates and later edits in the same turn. Reverting to the original YAML refreshes its context too. Invalid edits leave the previous context unchanged, so inspectors still reject a source mismatch. Permitted incomplete drafts remain available for direct YAML inspection. Saved optimizer contexts keep the data from their submitted schedules.

With the same full prompt, request-inspection-after-edit improved from 0/3 to 3/3 and request-inspection-after-creation from ⅓ to 3/3. The read-only request-filtered-cross-month control passed 3/3 in both versions. These comparisons used real provider requests and E2B commands with four case jobs. They measure inspector availability after accepted schedule changes.

The model receives Pi's four default coding tools: read, bash, edit, and write. read provides bounded text-file inspection with offsets and returns supported images as multimodal tool results. edit applies one or more unique, non-overlapping exact-text replacements against the same original file snapshot. write creates or overwrites one complete file. bash remains available for searches, checks, and complex operations using preinstalled Bash, Python with ruamel.yaml and PyYAML, ripgrep, grep, and diff. All relative paths resolve from /workspace. The application hydrates separate core, preference, and export schema documents under /reference for each turn. Each document groups related variants so the model can retrieve the context for one domain in one read instead of making a sequence of fine-grained lookups.

This follows the minimalism philosophy of the Pi coding agent: prefer a small set of general file and shell capabilities with discoverable documentation over a growing set of domain-specific tools. Nurse Scheduling retains stricter service boundaries than a local coding agent. The workspace is disposable, tool output is bounded, secrets and the stored schedule stay outside it, and a trusted application validates every possible schedule change and the final candidate.

Configured tool-round and tool-call limits bound the model-tool loop alongside per-command and complete agent-turn deadlines. One provider response has a defensive bound of 1,024 tool calls. Read-only batches run at most AI_AGENT_MAX_PARALLEL_TOOLS calls concurrently (default 16) and keep results in call order. Mixed or mutating batches run sequentially. A larger valid batch does not discard earlier workspace changes or replay the provider request.

The model-facing tool schemas and behavior are Python ports pinned to Pi v1.0.0. The read tool recognizes JPEG, PNG, GIF, WebP, and BMP files. Its multimodal result lets the model inspect an image extracted from another file. The sandbox also includes optional helpers: inspect_xlsx.py reads every worksheet by default and shows formulas alongside their last-saved cached values, while inspect_pdf.py extracts text by page and can render a selected page. The agent can write a focused parser in its sandbox when these helpers are insufficient.

The XLSX inspector accepts --range B3:D8 with --sheet, or a sheet-qualified range such as --range "'Sheet name'!B3:D8". A range selects a finite rectangle of at most 10,000 cells per sheet. It also accepts a single cell and absolute references such as $B$3:$D$8. The existing row and column window options remain available. Do not combine them or --overview with --range.

The tool-xlsx-a1-range case checks successful range execution and independently known saved formula caches. With the full prompt unchanged, three runs per version on unsloth/Qwen3.8-27B-NVFP4 improved from 0/3 to 3/3 after adding the range option. The xlsx-formulas-and-caches control passed 3/3 with both versions. These checks used real provider requests and E2B commands with four case jobs. They did not submit optimizer jobs or establish a token or latency saving. The Nurse Scheduling adapter delegates file and command operations to SandboxBackend. AI_SANDBOX_COMMAND_TIMEOUT_SECONDS defaults to 60 seconds and caps each command at that duration. The agent can run many commands within the separate turn deadline. The E2B command wrapper combines stdout and stderr as they arrive, as Pi's live output stream does. Nonzero and missing command exit codes produce failed tool results that the agent can read before continuing. GIF detection requires a complete GIF87a or GIF89a signature.

The v1.0.0 comparison covers all four tools, image detection and processing, and head/tail truncation. Edit/write execution and image processing are unchanged from the previous pin. Pi also adds programmatic Bash output and model-specific image resize profiles. This service exposes model-facing results only and keeps Pi's default 2000-pixel and 4.5 MB base64 image limits. Pillow replaces Photon's image codec and rejects oversized source images before decoding. Pi's terminal renderers and middle truncation are not used by these tools.

Autonomous roster validation

The agent helps build and edit schedule data by identifying missing requirements, ambiguities and possible oversights. It asks about unexplained items, groups, roles and qualifications without inventing rules. Clarification takes priority over automatic optimization even when the available data is sufficient for the optimizer. Overlooked requirements can make its output unusable and waste compute and user time. Unspecified rules need confirmation before being treated as unrestricted or informational.

The agent clarifies ambiguous goals or requested actions. It can apply clear edits within the stated requirements and flag missing data. It waits for pending answers before optimizing unless the user explicitly asks to optimize or proceed despite the unknowns. Requests not to optimize still apply.

Once the necessary questions are answered, the agent validates its schedule data changes by optimizing and inspecting the output. It can adjust the schedule or optimizer within the agreed requirements and repeat. New questions that need user judgment pause the loop unless the user explicitly asks to proceed without answers. The separate optimizer-goal prompt was removed to keep one policy for when to act.

Focused behavior checks live in core/tests/ai/. The guidance case allows schedule data edits and accepts helpful questions, explanations and options. The explicit-edit case checks that a clear action request is applied without another confirmation and that no optimizer call is attempted. Cases with optimizer: {invoked: false} expose the production tool and reject any call during the turn. The tester does not launch solver jobs or validate completion loops. Legacy AI tests remain for a dedicated removal branch after the test migration is merged.

After a successful optimizer start, the host emits a running message and ends the current exchange. It skips remaining tool calls in that batch and preserves queued user messages. The optimizer completion starts a review turn with the submitted source and workbook. Draft, explanation and explicit no-optimization requests retain their scope.

Schedule change lifecycle

A finished run that changed the schedule commits its validated schedule as the session schedule. The backend then emits schedule_applied with the YAML and the structural diff. Its replaced_queued_schedule flag reports that the run's schedule replaced a browser update queued during the run. The browser applies each run's result once through the normal YAML import path as one undo step. It shows a notice when that flag is set or when the browser schedule changed after its last synchronization. Undo restores the exact schedule held before replacement. A saved receipt keyed by run_id prevents replay from applying the same result again after Undo or a reload. The browser compares its own normalized YAML, so import formatting does not cause a notice. The browser computes no hash, because crypto.subtle is missing on plain-HTTP LAN origins.

AI runs belong to the server and continue without a browser connection. Browser edits, Undo, notices, and reconnects do not start, stop, or gate a run. Send synchronizes the current browser YAML through PUT /sessions/{id}/schedule before its next user-requested run, and records an app event for the agent. An update that arrives during a run does not change that run. The backend keeps only the latest such update and applies it after the run's entries, unless the run applied its own schedule, which then replaces it. Stop explicitly requests cancellation. Optimizer completion can start a server review without a client connection. This separation replaces the earlier approval and rejection flow, which tied server progress to browser decisions.

Each tool call that changes the working copy carries the trusted check text that the model received in schedule_check. The transcript shows it as Schedule check passed or Schedule check failed, so the browser shows every validation result that the model saw.

Figure 6. Trusted schedule review and browser application.

flowchart TB
    Candidate["<b>Sandbox candidate</b><br/>Untrusted schedule file"]
    Validation["<b>Trusted backend review</b><br/>Validate and compute a structural diff"]
    Session["<b>Session schedule</b><br/>Adopt only after the owning run commits"]
    Replay{"Run already applied<br/>by this browser?"}
    Import["<b>Frontend import</b><br/>Apply as one undo operation"]
    Notice["<b>Conflict notice</b><br/>If browser work changed, explain how to Undo"]
    Keep["<b>Replay receipt</b><br/>Preserve the browser's current schedule"]
    Candidate -->|Read after model execution| Validation
    Validation -->|Valid changed schedule and current RunSnapshot| Session
    Session -->|schedule_applied with run ID, YAML,<br/>diff, and replaced_queued_schedule| Replay
    Replay -->|No| Import
    Import --> Notice
    Replay -->|Yes| Keep

Earlier versions asked the user to approve each change. Saved sessions can still contain those decisions. They restore as the app events that the model received.

A run that fails, is cancelled, or is abandoned retains its user message and an interrupted assistant entry. It discards its candidate and workspace changes. Its provisional activity may remain visible in the browser. Later model context omits the interrupted assistant response and retains completed exchanges. A separate ABORTED_RESPONSE_HISTORY app event describes the discarded workspace. A successful run that only answers a question never changes the schedule.

If the final candidate fails trusted validation, the UI reports that every schedule change from the turn was discarded and that the current schedule was not changed. The failed turn retains its question and an interrupted assistant entry. Later model context omits that response and retains a separate ABORTED_RESPONSE_HISTORY app event.

After a Bash command changes the candidate, the trusted application returns an intermediate validation result so the model can repair it. The backend reads the final file as untrusted input and applies authoritative validation and a structural diff. Validation inside the sandbox is feedback only. It is never the acceptance boundary.

The provider boundary uses OpenAI-compatible chat completions. The Cloudflare Tunnel example shows one compatible deployment pattern.

Provider requests retry with bounded exponential backoff as described under Provider retries. A retry resends the original request and omits the failed partial response. Completed tool exchanges remain. Incomplete tool calls are discarded. Tools run only after a complete response. Text and reasoning limits and the sandbox-turn deadline apply across all attempts.

Replay-safe E2B requests also get three total attempts with exponential backoff. This covers file reads and replacements, automatic resume, and sandbox destruction. Auto-resume attempts also have an application-enforced control request deadline. Idle pause is attempted once because it is optional and a failed response cannot prove whether E2B accepted it. Retry logs include the operation, sandbox ID, attempt, delay, and exception. Sandbox creation and Bash execution are not replayed because a failed response cannot prove that the original operation did not take effect.

Configuration

See the core instructions for this task.

Run in the development container

See the core instructions for this task.

Run with Docker Compose

Follow the Compose startup instructions.

Session recovery storage

PostgreSQL recovery storage is optional for native runs and included in both backend Compose variants. Both variants use the postgres-ai-data volume and no published database port. Native runs enable storage with AI_HISTORY_POSTGRES_URL. Startup applies numbered migrations transactionally. Recovery is the only database history.

History entries and streaming

It saves the following data:

Table Stored data
chat_sessions Owner cookie hash, credential ID, creation time, expiry, and a state with the schedule and the number of conversation entries that retention trimmed.
chat_runs Run order, kind, client message_id, credential ID, model, status, error code, whether the run's messages joined the conversation (committed), the optional schedule_change application payload, attachment count, usage, and start and finish times.
chat_session_entries Conversation entries in session order, keyed by (session_id, seq): run messages and app events such as uploads and schedule changes. Run messages name their run. Compaction entries hold a summary and the number of preceding recent model messages to retain. Each row has a type and a JSON payload holding the fields of the matching transcript.py type.
chat_session_events Replayable session events except optimizer progress. Adjacent text fragments of one run share a row covering event_id through last_event_id.
chat_run_stops Named Stop requests, including messages that have not arrived yet.
chat_files Session file bytes as bytea, with a stable archive ID, kind, source run/job/upload ID, filename, media type, creation time, and removal time. Detached versions remain until session expiry. Active upload metadata and retry fingerprints stay in the session state.

Sessions are saved when they are created. Each run saves the session state with its prompt when it starts. As in Pi, each later message is saved when it ends, so a crash keeps the finished tool calls and results of an interrupted run. App events are saved as entries when they happen. The run's status and resulting state commit in one transaction when it ends. Migration 005_run_schedule_change.sql adds the application payload to that transaction. Recovery republishes it before the missing terminal event if a crash lost stream publication. Schedule updates save the state as well. A per-session lock commits these writes in capture order, so an older state never replaces a newer one. Each state write also saves every entry that is not saved yet, so the trimmed-entry count never skips an unsaved entry. Tool results can contain schedule and attachment text, so entries and events can contain staff information. Original uploads are stored with their session state and upload entries in one transaction. Removal detaches the file with the updated state and preserves its bytes. An upload save failure returns HTTP 503. A retry with the same upload ID keeps the original file. Generated downloads and optimizer files are stored too. Tool result images are not stored separately. Database access is for operators.

Published events are written after delivery, combining adjacent text fragments when writes fall behind. A failed batch is retried in order. A restored stream continues its event IDs well past storage, so a browser cursor of the previous process always receives a replacement snapshot. That snapshot also replaces the newest stored run, which the browser may show with output that storage lacks.

A session expires after AI_SESSION_TTL_SECONDS of inactivity, 30 days by default. Activity renews the expiry, so an active session can retain older content. Startup and hourly maintenance delete expired sessions with all their files, runs, entries, events, and Stop requests. Backups need their own retention policy.

An unavailable configured database prevents startup. A failed save of a new session or run start returns HTTP 503 before contacting the provider. A failed schedule update save returns HTTP 503, and the browser sends the update again with the next message. A failed final run write keeps the live answer, emits a warning, and sets history_saved: false on done. The next state save of that session writes the run outcome again. JSON values replace NUL characters, which PostgreSQL cannot store, with U+FFFD.

When a request names a session that is not in memory, the service restores it from storage for its owner cookie. As Pi's buildSessionContext does, restore rebuilds the conversation from saved entries: app events and the messages of committed runs. It then skips the entries that retention trimmed. Failed and interrupted runs can retain safe conversation context. Run finalization and restart recovery save the interrupted state of entries that the run already saved, so restore uses the entries as stored. Stale runs stay in the audit record only. Runs left running are reconciled with their saved terminal event. If no terminal event was saved, they end with an error that names the restart. Their saved question and output remain visible. A restart does not rerun model calls or mutating tools. Active upload and download references are restored. Finished optimizer workbooks remain available for download and follow-up inspection.

With PostgreSQL, a full store unloads the least recently used idle session instead of returning HTTP 429. A session stays loaded while a run, optimizer job, state write, entry write, or event write is unfinished, and after a failed save until a later save succeeds. Without PostgreSQL, a full store returns HTTP 429.

Migration 004_unified_session_recovery.sql preserves the existing recovery sessions and converts their saved conversation and output to this schema. See Recovery database upgrade for the transition and rollback procedure. Use the optional pgAdmin Compose profile for inspection. Mapping to Pi compares this storage with Pi's session storage.

Session replay uses one event journal and a replacement projection per session. Each has event-count and serialized-byte limits, with a combined process-wide byte cap. Event replay keeps at most 4 MiB of serialized events per session and 64 MiB for the process. It drops completed run output first and retains final events longest. Without PostgreSQL, an incomplete reset snapshot sets incomplete: true. These replay limits are separate from AI_MAX_SESSION_BYTES, which bounds live schedule, transcript, receipt, unsaved or memory-only upload, and download retention. Persisted upload sizes still count toward the attachment limits, independently of retained memory. PostgreSQL can supply output no longer retained by the in-memory projection. Replay limits do not cap database retention.

Alignment with Pi v1.0.0

The reference is Pi v1.0.0, checked out locally under the ignored artifacts/pi-v1.0.0/ directory. The survey covers both the coding agent and its separate durable package:

  • SessionManager stores typed JSONL entries with an ID, parent ID, and timestamp. AgentSession saves regular messages on message_end.
  • The agent loop updates the current assistant message as deltas arrive, then emits its final message. The provider EventStream queues live events and exposes a final result. That queue does not provide persistent browser replay.
  • The durable SQLite schema stores identified entries with commit order, tasks with explicit status, and submissions with request IDs. Its generation harness appends completed assistant messages as entries. This is a separate execution framework, not the coding agent's JSONL persistence path.
Pi principle Project choice and reason
Keep content entries separate from live deltas. Store combined content blocks and rebuild complete replay snapshots. Retain UI event kinds so tool activity, applied schedules, and steering keep their existing frontend behavior.
Give entries identity and order. Use session-local conversation sequence numbers and timestamps. Keep one session event cursor separate from conversation identity.
Store task and submission state explicitly. Keep unique client message IDs and explicit execution status in runs. Store accepted questions as typed conversation entries. The existing Stop records handle requests that arrive before acceptance.
Save completed messages. Queue typed session entries when model responses end and save live publications through an asynchronous replay writer. Final outcome writes include the resulting state and pending entries. A failed recovery write produces a warning and remains eligible for retry.
Support session branches and a general durable task framework. Keep linear conversations and the existing agent and optimizer workers. Branches, generic tasks, watches, documents, and Pi's SQLite runtime add machinery this app does not need.
Store content with associated metadata. Keep one recovery history. Store reporting metadata on sessions and runs, without separate copies of questions and answers in audit tables.

This aligns the storage and replay principles without importing Pi's runtime or changing the frontend protocol. A browser disconnect leaves the accepted agent running. A backend process crash recovers saved content and marks interrupted work failed. It does not rerun model calls or mutating tools.

Inspect recent user prompts

Run these commands from the repository root:

./scripts/recent_ai_prompts.sh
./scripts/recent_ai_prompts.sh --sessions 10 --prompts 3 --chars 160

The read-only command shows 15 recent sessions by default, with five user prompts per session and 300 characters per preview. Sessions sort by their latest user prompt, with prompts newest first. Output includes UTC timestamps, prompt counts, run status, and available run token totals. Steering messages are included. Automatic optimizer prompts and assistant text are excluded. Token totals include the whole run's context and tool exchanges, so steering messages in one run share them. ? means usage is unavailable.

The command prefers docker/.env. If it is missing, the command falls back to docker/.env.staging and prints a notice. The selected Compose environment file always appears on stderr. Set AI_ENV_FILE to select a file explicitly and disable fallback, or AI_COMPOSE_FILE to select docker/compose.backend.memory.yml. Set AI_HISTORY_POSTGRES_URL for native database access. The command does not migrate or prune the database. It shows retained rows, including expired sessions that have not been pruned.

Inspect chat history with pgAdmin

Start pgAdmin from docker/ with the same Compose file and environment file used by the deployment:

cd docker
docker compose --env-file .env -f compose.backend.yml \
  --profile inspection run --rm --service-ports pgadmin

For staging, replace --env-file .env with --env-file .env.staging. If the deployment uses a process-local optimizer, replace compose.backend.yml with compose.backend.memory.yml and keep the same environment file and project name.

It listens on the backend host's loopback port 5050. For a remote host, forward that port with ssh -L 5050:127.0.0.1:5050 user@backend-host. The backend deployment guide covers the Compose services and environment file.

Open http://127.0.0.1:5050 and sign in with [email protected] / pgadmin. Under Nurse Scheduling, connect to AI chat history with database password ai_history. The server definition is preloaded. In Tools > Query Tool, inspect recent entries:

SELECT e.created_at, e.session_id, e.seq, e.type, e.payload, r.kind,
       r.status, r.error_code, r.model, r.auth_credential_id
FROM chat_session_entries AS e
LEFT JOIN chat_runs AS r ON r.id = e.run_id
ORDER BY e.created_at DESC, e.seq DESC
LIMIT 100;

Press Ctrl+C when finished. Compose removes the temporary pgAdmin container; the PostgreSQL service and its postgres-ai-data volume remain intact.

Production path proxy

The backend Compose deployment routes Cloudflare Tunnel traffic through NGINX. It sends /ai/* to the AI service and all other paths to the optimization API. Disable response buffering on the streaming route. See the NGINX proxy buffering directive.

location /ai/ {
    proxy_pass http://ai:8001/;
    proxy_http_version 1.1;
    proxy_buffering off;
    proxy_cache off;
}

The Cloudflare public hostname must target http://nginx:8080. The trailing slash on proxy_pass removes the public /ai prefix before the request reaches FastAPI.

Export saved chats

The single Export Chat control downloads a ZIP from the AI service. The browser and operator command use the same Python archive writer. See Export saved AI chats for the one-command Docker and native database workflow. Export requires PostgreSQL history.

The live transcript and offline HTML use the same transcript stylesheet. The browser rendering test replays a deterministic conversation through the real frontend event handling and compares it with freshly generated Python HTML. It checks content, order, disclosure states, computed styles, and screenshots at desktop and phone widths, including after reload. Computed styles must match exactly. Screenshot comparison allows one RGB level of browser edge rounding. Frozen export hashes provide separate output regression checks.

Exports use UTC timestamps and archive-relative file links. Copy buttons and session actions remain in the live app. Native disclosures keep reasoning, tools, long output, and provider costs expandable in offline HTML without scripts.

The Python ZIP writer is available through python -m nurse_scheduling.ai.export_chat SESSION_ID OUTPUT.zip with AI_HISTORY_POSTGRES_URL set. It includes chat.html, chat.md, chat.json, schedule.yaml, manifest.json, and every archived file under attachments/. Both documents link to those files. The manifest records their origin, creation and removal times, byte counts, and SHA-256 hashes. The JSON keeps the saved events, run status, and current session state, including unfinished work.

The writer uses one PostgreSQL repeatable-read snapshot for text, metadata, and file contents. It reads file bytes in chunks of at most 1 MiB. HTTP delivery uses a temporary disk file and closes it after completion or disconnect. Export needs temporary disk space for the ZIP. The command writes atomically so a failed export preserves an existing destination.

The browser first sends an authenticated POST /sessions/{session_id}/export. It receives a five-minute download token bound to that session. Minting requires the owner cookie. Redemption does not, because browsers do not always send the cookie with the download navigation. The archive is read from storage, so a link also works for a session that the service has not loaded. The subsequent native GET streams the ZIP without a browser Blob. The token uses the existing signing primitive with a separate export scope and never contains the bearer credential. The request-limit middleware and route both check it. A bearer-authenticated GET remains available for API clients. Both responses use Cache-Control: no-store. The browser opens its download tab during the click, before awaiting the POST, so slow preparation does not lose popup permission. Preparation errors appear in the chat. Native GET errors appear in the download tab, leaving the chat available for retry. The ZIP is never buffered in JavaScript.

HTTP API

Session creation and both foreground and queued messages include frontend_version, the browser's build version. The AI service compares it with its own build, logs a mismatch once per loaded session, and includes both versions in the error record sent through Sentry's logging integration. The request still runs. The browser shows the same warning as Optimize and Export below its token controls. /capabilities.app_version identifies the AI service. The parent /info version is a fallback for older deployments.

Endpoint Purpose
GET /health Process and service identity check.
GET /ready Required configuration accepted at startup.
GET /capabilities Public attachment limits, session lifetime, and authentication requirement.
POST /sessions Store a YAML snapshot and create a browser-owned session.
GET /sessions/{id} Check the remaining session lifetime without renewing it.
POST /sessions/{id}/export Authorize a native ZIP download with a short-lived, session-scoped token.
GET /sessions/{id}/export Stream the complete chat ZIP with a bearer credential or the scoped download token.
POST /sessions/{id}/uploads?upload_id={uuid} Retain one multipart file under a stable retry ID and return its metadata. Omitting the ID also accepts an unkeyed file batch.
GET /sessions/{id}/uploads List retained file metadata.
DELETE /sessions/{id}/uploads/{upload_id} Remove one retained file.
POST /sessions/{id}/messages Stream one answer to a JSON message.

The default bounds are 128 retained files, 50 MB per file, and 500 MB of retained source files per session. MB uses 1,000,000 bytes. With PostgreSQL, AI_MAX_ARCHIVE_BYTES also limits the file bytes stored per session, 10 GB by default. It counts every stored upload, generated ZIP, and optimizer file, including removed files, because removal keeps the stored copy. A full session refuses new uploads with HTTP 413. It keeps the answer without a new generated ZIP, and an optimizer result that cannot be stored reports this limit to the agent. Restore recounts the stored bytes from chat_files. /capabilities.file_attachments publishes max_files, max_bytes_per_file, and max_bytes_per_session. The browser checks existing uploads and selected files against these limits. It uploads one file per request, sequentially, before submitting the question. A connection failure or server error is retried up to three times. Each file uses one UUID in the upload_id query parameter across every attempt. A retry returns the original metadata without adding another file or charging storage again. The server rejects reuse with a different filename, media type, or content. With PostgreSQL, upload IDs and file bytes survive restarts and stay archived until the session expires. Removal detaches a file from active use. Without PostgreSQL, uploads remain process-local and disappear on expiry, removal, or restart. If an upload still fails, earlier successful uploads remain in Session files. The question is not submitted, and the retry asks only for the files that did not upload. Remove unused uploads to free session capacity. The backend bounds each streamed request body to the per-file limit plus 64 KiB for multipart overhead. NGINX permits 64 MiB per /ai/ request. JSON requests have a 64 MiB body bound before parsing. Questions have no separate character limit. Provider requests must fit the selected model's context window with its response budget and safety margin.

Upload requests contain only repeated files fields. Other field names are rejected. Uploads are refused while a response is active. Every turn can read all retained files. PostgreSQL restores saved sessions after restarts. Live runs and sandbox ownership remain process-local, so use one AI backend instance.

Provider request layout

Providers reuse cached prompt work only for an identical request prefix. Each request therefore keeps earlier content unchanged and puts changing state last:

  1. The system message. It does not change during a chat.
  2. The chat history. Each entry stays unchanged once it is stored.
  3. The user's question, exactly as typed.

The backend stores app events as separate user messages in history when they happen. Each event starts with [App event]. Events record uploads, removals, and schedule changes made in the app. An upload event lists the original name, sandbox path, media type, and size of each file. A schedule change is recorded only when the parsed schedule data changes. Filenames stay out of the system prompt because they are untrusted input.

The backend does not append automatic status messages or restore workspace facts lost during compaction. Historical status messages remain unchanged. Each VM receives TZ once at creation from the browser timezone. The agent can use bash date when it needs the current date or time.

Defensive history and memory limits refuse new work without discarding completed exchanges. Normal compaction follows Pi token settings. See AI runtime limits and Pi comparison for defaults, remaining differences, and deployment capacity controls.

Each assistant turn starts with a model_input event. Its system field contains the system message. Its messages field lists the request messages added since the last assistant reply, in order. Each entry has a kind of app, question, or optimizer. Historical events may also contain status. An app entry also has its absolute transcript entry index, so a client shows it once when a failed turn is retried. Compaction and retention preserve this index. App events also have a short title, such as Files Uploaded or Schedule Changed. The chat shows it after the role label and keeps the exact text collapsed. Foreground and background runs publish this event on the session event stream after run_start.

GET /health, GET /ready, and GET /capabilities stay public so deployment probes work and the frontend can discover authentication and attachment limits. Capabilities reports whether bearer auth is active. When either AI key setting is set, every session route requires Authorization: Bearer <key> and returns 401 when the credential is missing or wrong. Native runs may leave the key settings unset to serve locally without auth. Docker Compose sets AI_AUTH_REQUIRED=true on the service, so its env file must explicitly set AI_AUTH_REQUIRED=false and leave both key settings empty to serve without authentication. Required mode refuses to start with a missing, blank, shorter than 16 character, or non-ASCII key. AI_AUTH_TOKEN remains supported for backward compatibility.

For example, create a session directly with:

curl -H "Authorization: Bearer ${AI_AUTH_TOKEN}" \
  -H "Content-Type: application/json" \
  --data '{"schedule_yaml":"description: test"}' \
  http://localhost:8001/sessions

Security notes

  • Keep docker/.env private. Git ignores it, while docker/.env.example contains only empty secret fields and documented defaults.
  • The browser keeps the AI token in memory unless the user explicitly chooses to store it unencrypted on that device. A stored token is scoped to the AI endpoint that requested it. AI keys are independent from optimizer keys, although an operator may configure equal values.
  • E2B Cloud is currently the only sandbox backend. The agent depends on the project SandboxBackend contract so a future self-hosted E2B or remote gVisor backend does not require changing model logic.
  • The trusted application creates E2B sandboxes with outbound Internet access disabled. It does not pass the E2B key, model provider key, database credentials, host paths, or the stored schedule into the sandbox.
  • Treat shell commands and every sandbox file as untrusted. A sandbox can only return a candidate schedule. Trusted validation, revision checks, and updates to the current schedule remain outside it.
  • Use AI_COOKIE_SECURE=0 only for local HTTP. Set it to 1 when the public browser route uses HTTPS, even if NGINX uses internal HTTP to the container. Secure deployments set the owner cookie to SameSite=None; the CORS origin allowlist still limits which browser origins may make credentialed requests.
  • The allowlist accepts nursescheduling.org and its subdomains over HTTPS, and loopback origins over HTTP, as on the optimizer API. It refuses private-network origins such as http://192.168.0.10:3000, because any device on a shared network can serve one. Open a development frontend through localhost or 127.0.0.1.
  • The owner cookie contains an opaque UUID, not the provider key. It is not a replacement for future account authentication.
  • The complete schedule is sent to the AI service. Relevant content is sent to the configured provider through model-facing tool results. Use approved services and anonymize sensitive schedules when required.
  • Arbitrary uploads are bounded, assigned safe sandbox paths, and treated as untrusted data. Session requests authenticate before reading their bodies. The AI service checks declared and streamed body bytes before parsing JSON or multipart data. JSON bounds allow escaped schedules and messages. Only upload routes receive the larger attachment body budget. Configure a matching request-body limit at the public reverse proxy. Never add attachment execution to the sandbox workflow.
  • The Pi-compatible read tool recognizes JPEG, PNG, GIF, WebP, and BMP content. It normalizes and bounds images before returning them to the model as multimodal tool results.
  • The XLSX helper disables external links and reports formulas with their last-saved cached values. It does not recalculate formulas. The PDF inspector reports text-extraction and page-limit gaps, and its optional page rendering is bounded by a pixel budget. PDF helpers reject encrypted files.
  • Assistant answers use a safe Markdown renderer. Raw HTML is disabled and remote Markdown images are omitted to prevent third-party requests.
  • Provider HTTP errors return a searchable error ID to the browser. The backend logs the upstream response body under that ID after redacting common credential forms.
  • A failed or cancelled turn retains its question and an interrupted assistant entry. Later model context omits the partial response, retains completed exchanges, and appends the ABORTED_RESPONSE_HISTORY workspace notice.

Troubleshoot local development

See the core instructions for this task.

Attachment capability discovery

See the capability discovery instructions.

Validate

Follow the AI test instructions. The opt-in test_ai_compaction_live.sh command exercises the real session API and E2B with five long-chat scenarios, at most four concurrently. Explicit compaction token settings force compaction. The scenarios check constraints, complete tool exchanges, manual compaction, Stop, reconnect replay, and fresh sandbox filesystems. They also check that model changes return HTTP 409 and supported reasoning-effort changes remain accepted. The command uses synthetic test conversations. It does not change the production prompts or connect to a production database.

Mapping to Pi

The agent/session boundaries and selected tool ports follow Pi v1.0.0 (a13d35a). This comparison covers its public agent loop and coding-agent session. Source links are pinned to the full release commit. These are architectural counterparts, not identical APIs. Each row lists shared behavior first, then what only one side has.

Component Shared Ours only Pi only
Agent / AgentState
Pi: Agent, AgentState
Streaming flag, pending tool call IDs, in-run messages, the steering queue, and refusal of a second concurrent prompt. Context arrives per run, and the session commits its retained transcript after cleanup. AgentRun, not Agent, owns cancellation, so Stop also reaches queued runs and cannot interrupt cleanup. State also holds the model, thinking level, tools, persistent transcript, and partial streaming message. The agent becomes idle only after awaited agent_end subscribers finish.
agent_loop
Pi: agentLoop
Repeats model responses and tool batches. Steering enters before each follow-up request, or continues the run when it arrives as the answer ends. Tool calls from a response cut off by the output limit fail without running. A batch runs concurrently only when every call is read-only. Round and call budgets end with an answer-only request, and a refused truncated batch spends a round. Each request passes through one context projection. Parallel execution by default. The whole batch runs sequentially if configured globally or required by any tool in it. Supports before and after tool-call hooks and context transform hooks. Tool-result early termination requires every finalized tool result in the batch to set terminate: true.
AgentTool / AgentToolOutcome
Pi: AgentTool / AgentToolResult
A model-facing definition bound to execution. Results carry model content, UI details, and explicit failure status. Start and end events correlate by tool call ID. Tools receive raw JSON arguments, return text, an optional image, and an ok flag, and declare whether they are read-only. Schema-validated parameters, the call ID, an abort signal, and partial-update callbacks. Tools throw or return isError: true on failure. structuredContent follows the tool's outputSchema for programmatic callers and is excluded from model input.
AgentSession / RunOutput / AgentMessage
Pi: AgentSession, AgentMessage
An application layer over Agent. Runs produce UserMessage, AssistantMessage, and ToolResultMessage records shaped like Pi's messages, with Pi's stop reasons, persisted in order. Later model input excludes aborted answer content. Snapshot-versioned commits after sandbox cleanup, schedule revisions, and applied schedule entries. The in-memory session keeps prompts, native reasoning metadata, complete tool exchanges, assistant text and stop reasons, and schedule changes. A stopped run retains its prompt and aborted partial answer. Later model context omits only the failed response and appends a separate note describing the discarded workspace. With PostgreSQL, a session continues after a restart or unload, but it cannot branch. An append-only, branchable JSONL session with model and label entries, automatic compaction into summary entries, automatic retry of retryable errors, and extensions. Aborted and error assistant messages stay in session history, but provider message transformation omits them from later model replay. Context-edit entries can omit recovery attempts and their tool results from subsequent context while retaining the raw history.
SessionRuns / AgentRun / RunSnapshot
Pi: ActiveRun and run lifecycle
One active run with a cancellation handle, finishing before the next run starts. Per-session FIFO execution, queued background runs, Stop that also cancels queued runs, and a versioned commit capability. A Stop can name a client message. It cancels only that message's run and also stops the message if it arrives later. Service shutdown leaves runs unfinished, so the restarted service reports them as interrupted. The agent's own AbortController, passed to tools, with a single-run guard instead of a queue.
Steering queue
Pi: steer / followUp
Queued input reaches the model at a boundary without starting a new run. One queue serves both roles. It drains every message, deduplicates retried POSTs by ID, and closes atomically with the answer. Optimizer wake-ups enter SessionRuns as fresh runs. Separate steering and follow-up queues, each draining one message at a time or all at once.
WorkspaceTools / _LazySandboxTurn
Pi: read, bash, edit, write
Pi's default read, bash, edit, and write contracts and output behavior, ported under ai/pi. A lazy, disposable E2B VM per run with hydration, pause and resume, YAML validation after each change, and teardown before commit. A shorter read description, fixed image limits with a source-pixel bound, and server command timeouts. Tools act on the user's local working directory, which persists across runs. Image resize options can follow the active model's limits. Built-in powershell, find, grep, and ls tools are available beyond the default active four. Nested calls through ctx.executeTool() share validation and hooks and carry parent tool call IDs.
AgentSessionEvent / SessionEventStream
Pi: AgentEvent, AgentSessionEvent
Typed text, reasoning, and tool events with call IDs and exactly one terminal outcome per run. AgentSession.subscribe exposes one typed public event per publication to in-process listeners. SessionEventStream retains all run and job events for HTTP replay with Last-Event-ID. With PostgreSQL, replay continues after a restart. Disconnect detaches the reader. Explicit Stop cancels runs. In-process subscribers receive agent, turn, message, and tool lifecycle events, including partial tool updates. Low-level agent_end marks the end of one loop run. AgentSession emits agent_settled after retries, compaction recovery, queued continuation work, and session boundary processing finish.
SessionRecovery / ChatHistory
Pi: SessionManager, durable storage
Typed entries saved in session order, each message when it ends. A later process rebuilds the conversation from the saved entries. Client request IDs make a repeated submission return its existing run. Optional PostgreSQL storage. Linear entries also hold app events. A run's messages join the conversation only when the run commits, so restore retains safe context from failed or interrupted runs and skips stale runs. The state holds the schedule and a count of entries that retention trimmed. Ordered compaction entries hold summaries and the number of recent messages to retain. Published events and named Stop requests are saved too, so a restart keeps the output that a browser saw. Entries form a tree with branches, compaction summaries, model changes, and labels. The separate durable package stores generic tasks with explicit status.
SessionOptimizer
Pi: no first-class built-in counterpart
Exposed to the model as one AgentTool. Independent remote jobs, progress, anonymization, late-submission cleanup, and fresh review runs. Similar behavior could be implemented through extensions.
API routes / SessionStore / browser lifecycle
Pi: nearest is AgentSession
A session boundary that owns conversation lifetime. HTTP authentication, cookie ownership, expiry, session text limits, shared SSE framing and recovery for GET and compatibility POST readers, a browser-owned schedule, and automatic application of validated changes. With PostgreSQL, a request restores a saved session for its owner cookie, and a full store unloads the least recently used idle session. Local single-user sessions stored on disk that can be resumed and branched.

Design decisions

The investigated alternatives below were not adopted, except where noted:

  • Separate steering and follow-up queues. The browser offers one queue action. Optimizer results start a fresh run because they can arrive after the run ends and must see the current schedule.
  • Summary compaction. The agent now summarizes older context and retains recent tool exchanges. Hard session retention limits still bound service memory.
  • Central schema validation of tool arguments. The ported Pi tools already validate with Pi's wording and accept the compatibility shapes Pi's prepareArguments accepts, such as a legacy single edit. A generic schema check would reject those shapes first. A contract test instead requires every offered tool to refuse malformed arguments before any sandbox command, file change, or optimizer submission.
  • Recording failed attempts in the transcript. Failed and interrupted runs retain the question and an interruption note instead of replaying failed answer content. Retry starts a new run. The browser replaces the failed pair on retry, while the chat history log keeps each attempt's outcome and ordered transcript as its lineage.