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.
Image and document attachments are enabled by default and can be disabled independently. Supported documents are TXT, Markdown, CSV, PDF, and XLSX. The assistant reads and edits the schedule in a disposable sandbox and can propose a new schedule, which the browser applies only after the user approves it. Each user message gets one temporary shell backed by E2B Cloud. This version excludes retrieval and repository access.
Run locally¶
Copy the shared secret-free template from the docker/ directory:
Review the AI assistant block in docker/.env. Set the provider URL, API key,
model, and other settings for your environment. For an authenticated service,
also set one or more AI keys. To serve locally without auth, explicitly set
AI_AUTH_REQUIRED=false and leave AI_AUTH_TOKEN and AI_AUTH_TOKENS empty.
Then start the service:
The launcher reads docker/.env automatically. Set AI_ENV_FILE to load
another path. Port 8001 avoids the normal backend on 8000. Use another port
for a local documentation server when both services run at the same time. The
documented local Zensical port is 8003.
The frontend uses https://api.nursescheduling.org/ai by default. Its server
control can select http://localhost:8001 or a custom URL, and locks that
selection after a conversation starts. Set NEXT_PUBLIC_AI_API_URL before
building the frontend to provide a different deployment default. Remembered
credentials are stored unencrypted per endpoint only when the user opts in.
Architecture¶
flowchart LR
Browser[Frontend<br/>current schedule] -->|POST schedule once| Session[AI backend<br/>in-memory session]
Browser -->|POST question<br/>and optional attachments| Session
Session -->|OpenAI-compatible chat request| Provider[Model provider]
Provider -->|streamed deltas and tool calls| Session
Session -->|one fresh turn| Sandbox[E2B Cloud sandbox<br/>shell working copy]
Sandbox -->|candidate schedule| Validation[Trusted server validation<br/>and structural diff]
Session -->|SSE text, reasoning, tool, validated change, and proposal events| Browser
Browser -->|approve with base revision| Session
The browser receives an HTTP-only owner cookie and an unguessable session UUID. The backend stores the YAML snapshot and completed conversation turns. Each provider request includes the stored YAML, recent history, and current question. Enabled attachments are included only in the active provider request. Images and document contents are not included in subsequent chat history. This is intentional to avoid repeatedly consuming provider context tokens. History retains only attachment markers and document filenames. Schedules and attachments are labeled as untrusted data in the system prompt.
Sandbox and conversation state are separate. The backend copies the current
schedule to /workspace/schedule.yaml and searchable schema documentation to
/reference, runs every command for that user message in the same sandbox,
reads the candidate, and destroys the sandbox. A later message always starts a
new sandbox. Only conversation history, the canonical schedule revision, and a
pending validated proposal 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. Retry resends the original text in a fresh sandbox. For a request with attachments, Prepare retry restores the text and requires the files to be attached again before sending.
Reasoning and tool activity¶
Providers stream reasoning in a field of its own, either reasoning_content or
reasoning, and the adapter forwards it as a separate event. It is never joined
to the answer text, never stored in conversation history, and never sent back to
the provider, so it cannot leak into an assistant message and costs nothing on
later turns.
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. Sandbox backends return raw command output to the AI layer. The AI
bash adapter combines stdout and stderr, keeps the last 2,000 lines or 50 KB,
and stores the full output in the temporary sandbox when truncation occurs.
This policy stays outside the provider-neutral sandbox interface.
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.
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 create or apply a proposal. The final validated
candidate still follows the separate proposal and approval lifecycle.
Evaluation¶
The assistant is evaluated against fixed cases with verifiable criteria. This is evaluation, and is separate from the solver performance benchmark described in the backend server guide.
core/tests/ai_eval/cases/ holds the cases grouped by category, from questions
answerable from the prompt summary through schedule edits to requests that must
be refused. Each case states criteria over the schedule a run produces, so
grading does not depend on how the assistant reached it. Every run contacts the
configured provider, so this is a manual tool rather than part of CI.
The runner needs the same provider settings the service uses:
| Variable | Required | Purpose |
|---|---|---|
AI_PROVIDER_BASE_URL |
Yes | OpenAI-compatible endpoint. |
AI_PROVIDER_API_KEY |
Yes | Provider bearer token. |
AI_PROVIDER_MODEL |
No | Defaults to local-model. |
AI_SANDBOX_BACKEND |
Yes | Use e2b. |
E2B_API_KEY |
Yes | E2B Cloud credential used by the trusted application. |
E2B_TEMPLATE |
No | Defaults to nurse-scheduling-ai-sandbox. |
AI_EVAL_ARTIFACT_ROOT |
No | Report root, artifacts by default. |
The launcher reads them from docker/.env, so the shortest form is:
./scripts/run_ai_eval.sh
./scripts/run_ai_eval.sh --full
./scripts/run_ai_eval.sh --category 01-reading
The default run selects cases tagged difficult or tuning, keeping prompt
tuning focused as the corpus grows. Pass --full to run every case. Explicit
--case, --category, or --tag selectors bypass the default tag filter.
Cases may use user_turns for a real multi-turn conversation and
intermediate_answer_contains to verify that earlier turns ask a required
question without producing a proposal.
The launcher checks provider authentication before provisioning any E2B
sandbox. Providers without a /models endpoint produce an inconclusive result
and continue.
To run it without the launcher, load the settings first:
Select cases with --case and --category, both repeatable. The runner creates
and destroys one E2B sandbox per case, so start with selected cases before
running the complete evaluation.
Every run writes a report to its own directory under
artifacts/ai-evals/<timestamp>/, alongside the performance benchmark reports,
and prints the path when it finishes. summary.md holds the pass count, median
seconds, LLM inference time, provider HTTP attempt and retry counts, and the
aggregate sandbox timing per category. It also includes per-case tables for
every sandbox timing and suspension metric. results.jsonl holds one line per case, and
cases/<id>.json holds the whole run for one case: the prompt it was given,
its reasoning, every tool call with its arguments and result, the answer, the
proposed schedule, timing breakdown, and each criterion with its outcome. Pass --output-dir to
choose the directory, which must not already exist, or set
AI_EVAL_ARTIFACT_ROOT to move the root.
Each case also records tool batches, calls per model turn, calls per batch, parallel execution, execution time per batch, and the number of batches containing multiple calls. The summary lists batch counts beside sandbox pause metrics so pause behavior can be checked at model-turn boundaries instead of inferred from the total tool count.
To measure E2B read concurrency without provider or model variance, run:
The benchmark alternates repeated sequential and concurrent read batches in one
warm sandbox. It reports median and p95 latency plus the median speedup under
artifacts/ai-read-benchmarks/. Use --runs, --calls, and --bytes to change
the sample count, calls per batch, and file size.
Timing fields use wall-clock seconds. end_to_end_seconds covers the agent run.
llm_inference_seconds sums only time awaiting provider stream events.
llm_turn_seconds records that wait separately for each logical model turn.
provider_requests reports the underlying HTTP attempts, retries, retried
turns, and attempts per logical turn. A successful retry therefore remains
visible in both the case artifact and aggregate summary.
sandbox.lifetime_seconds covers the complete create-to-destroy lifecycle. Its
mutually exclusive components are provisioning, execution, pause transition,
warm waiting, suspended, resume wait, and teardown. Their sum equals the
sandbox lifetime. Resume wait is the blocking interval after work needs the
sandbox but before E2B has made it usable, and max_resume_wait_seconds exposes
the worst individual resume. The sandbox.suspension object reports pause and
resume counts. It also reports pause_cancel_count for an in-progress pause
cancelled when new sandbox work arrives. A pause cancelled before its E2B
request starts or during final cleanup is not included. LLM inference can
overlap warm waiting, pause transition, and suspended time by design.
After each sandbox operation, the E2B backend schedules an explicit warm-memory pause. Immediate follow-up activity cancels a pause that has not started, so hydration and other consecutive operations stay together. Otherwise the pause transition can overlap model inference, and E2B auto-resumes the same sandbox when the next operation arrives. The memory snapshot is retained because five fresh disk-only resume trials took 5.95 to 12.62 seconds, with an 8.01-second median. Commands, file operations, pause/resume transitions, and close share one serialized lifecycle lock.
Pause is optional optimization work and has a five-second application deadline. The longer background deadline does not delay foreground work because new activity cancels an in-progress pause. A failed or timed-out pause is not retried. Because a timeout cannot prove whether E2B accepted the request, the next operation first uses the separately bounded, replay-safe auto-resume probe. A later idle pause may still be attempted because the control-plane failure may have been transient.
The E2B creation timeout is not the hard deadline. E2B 2.46.0 testing showed
that an on_timeout=kill deadline did not kill a manually paused sandbox. The
application-level maximum agent-turn deadline and explicit kill in finally
are therefore the authoritative hard deadline. A separate live check confirms
that after this explicit kill, E2B rejects resume with SandboxNotFoundException.
Each created sandbox carries non-secret application ownership and hard-deadline
metadata. A completed kill response confirms either that the sandbox was
killed or was already absent. If deletion cannot be confirmed within request
cleanup, the sandbox ID enters a background queue that retries with capped
exponential backoff. At application startup and every configured reaper
interval, a metadata-filtered scan covers both running and paused sandboxes and
queues overdue instances. This avoids a full-account scan and lets a restarted
process recover cleanup work after a crash. If the application remains down,
no in-process cleanup can run, so deployments requiring cleanup during a full
service outage should invoke the same reconciliation from an external job.
The AI service initializes the shared Sentry integration with the
app=ai-backend tag. An unconfirmed request cleanup is logged as a warning.
An overdue sandbox or three consecutive background deletion failures is logged
as an error. All later attempts remain visible as warning logs, and a successful
cleanup is logged as confirmation. Sentry log alerts can use these severities
and the structured sandbox cleanup fields to notify administrators without an
error event for every retry.
Even a successful foreground kill is checked again in the background after one
E2B control-request timeout. This settling period covers a late pause or resume
request that can otherwise make a sandbox reappear after the kill response.
Confirmation lists only application-owned running and paused sandboxes. A
still-present ID is killed again with capped exponential backoff.
Deployments can run the same metadata-filtered cleanup independently of the AI service with:
The command requires only E2B_API_KEY, performs one reconciliation and
deletion pass, and returns a nonzero status if listing fails or any deletion is
still unconfirmed. Schedule it periodically when overdue sandboxes must be
cleaned while the AI service is offline.
Agent capabilities¶
The model receives Pi's four default coding tools: read, bash, edit, and
write. read provides bounded text-file inspection with offsets. 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, 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 canonical storage stay outside it, and a trusted application validates every possible schedule change and the final candidate.
The model-tool loop has no count-based tool-call limit. Per-command and complete agent-turn deadlines bound execution instead.
The model-facing tool schemas and text behavior are Python ports pinned to Pi
commit e266507.
Pi's image-read result is intentionally omitted because this service's tool
result channel is text-only. Image attachments continue through the existing
provider attachment path. The Nurse Scheduling adapter delegates file and
command operations to SandboxBackend and enforces the configured command
timeout ceiling. E2B returns completed stdout and stderr separately, so the
adapter concatenates them and cannot reproduce Pi's live stream interleaving
exactly.
Proposal lifecycle¶
A finished run that changed the schedule leaves one pending proposal. The
browser receives its structural diff, never its YAML. POST
/sessions/{id}/proposal/approve requires the SHA-256 of the schedule the
browser holds, so a proposal built on an older schedule is discarded instead of
applied. The approved schedule is revalidated before it is returned, becomes the
session schedule, and the browser applies it through the normal YAML import path
as one undo step. POST /sessions/{id}/proposal/reject drops it, and PUT
/sessions/{id}/schedule replaces the snapshot when the schedule changed
elsewhere in the app, which also drops any pending proposal.
Approval and rejection add a backend-only user-action note to model history. The rejection note says that every schedule change from the proposed turn was discarded and that the next turn starts from a fresh copy of the canonical schedule. It never includes the discarded YAML.
A run that fails, is cancelled, or is abandoned does not commit its user message, assistant response, or candidate proposal. Its provisional activity may remain visible in the browser, but the next turn starts from the last successfully committed history and canonical schedule. A successful run that only answers a question never creates a proposal.
If the final candidate fails trusted validation, the UI reports that every schedule change from the turn was discarded and that the canonical schedule was not changed. The failed turn does not add a history note.
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.
A provider request gets three total attempts by default. A timeout before the first streamed event waits one second before the second attempt and two seconds before the third. Once text, reasoning, token usage, or a tool call has reached the application, the request is not replayed because that could duplicate visible output or tool work. The complete sandbox-turn deadline still applies across provider attempts and may end a turn before every retry is available.
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 type without response contents. Sandbox creation and Bash execution are not replayed because a failed response cannot prove that the original operation did not take effect.
Configuration¶
| Variable | Default | Purpose |
|---|---|---|
AI_AUTH_TOKEN |
Unset | Shared bearer token. Setting it protects every AI session route. Use at least 16 ASCII characters. |
AI_AUTH_TOKENS |
Unset | JSON object mapping administrative IDs to bearer keys. |
AI_AUTH_REQUIRED |
false (true in Docker) |
Fail startup unless at least one key of 16 or more ASCII characters is configured. |
AI_PROVIDER_BASE_URL |
Required | OpenAI-compatible API base URL. |
AI_PROVIDER_API_KEY |
Required | Provider bearer token. Never commit it. |
AI_PROVIDER_MODEL |
local-model |
Model value sent to chat completions. |
AI_HISTORY_POSTGRES_URL |
Unset | PostgreSQL connection string for durable chat logging. Compose sets its internal URL directly. |
AI_HISTORY_RETENTION_DAYS |
30 |
Positive number of days to retain chat text and metadata. |
AI_REQUEST_LOG_ENABLED |
true |
Log a question preview for each incoming message, which records chat text. |
AI_PROVIDER_TIMEOUT_SECONDS |
120 |
Provider request timeout. |
AI_PROVIDER_MAX_ATTEMPTS |
3 |
Total attempts for a provider request that times out before streaming begins. |
AI_PROVIDER_RETRY_BACKOFF_SECONDS |
1 |
Initial pre-stream timeout retry delay. The delay doubles after each failed attempt. |
AI_SANDBOX_BACKEND |
Required | Sandbox provider. Currently e2b. |
E2B_API_KEY |
Required for E2B | E2B Cloud credential used only by the trusted application. |
E2B_TEMPLATE |
nurse-scheduling-ai-sandbox |
Prebuilt E2B template alias. |
AI_SANDBOX_COMMAND_TIMEOUT_SECONDS |
10 |
Default and maximum deadline for one shell command. |
AI_SANDBOX_TURN_TIMEOUT_SECONDS |
900 |
Deadline for the complete sandbox-backed user message. |
AI_AGENT_MAX_TOOL_ROUNDS |
10 |
Maximum model tool-call rounds before the agent must answer from verified results. |
AI_AGENT_MAX_TOOL_CALLS |
20 |
Maximum total tool calls in one sandbox-backed user message. |
AI_SANDBOX_CLEANUP_TIMEOUT_SECONDS |
10 |
Deadline for destroying a sandbox. |
AI_SANDBOX_MAX_ATTEMPTS |
3 |
Total attempts for replay-safe E2B requests. |
AI_SANDBOX_RETRY_BACKOFF_SECONDS |
0.5 |
Initial E2B retry delay, doubled after each failure. |
AI_SANDBOX_PAUSE_REQUEST_TIMEOUT_SECONDS |
5 |
Deadline for the cancellable background pause request. |
AI_SANDBOX_CONTROL_REQUEST_TIMEOUT_SECONDS |
2 |
Deadline for each foreground auto-resume attempt and the E2B request timeout for destruction. |
AI_SANDBOX_REAPER_INTERVAL_SECONDS |
30 |
Interval for reconciling overdue running or paused E2B sandboxes owned by this application. |
AI_BACKEND_PORT |
8001 |
Port used by the development launcher. |
AI_COOKIE_SECURE |
0 in the launcher |
Use 0 for local HTTP and 1 for public HTTPS. Secure deployments use SameSite=None so approved cross-site frontends can retain session ownership. |
AI_SESSION_TTL_SECONDS |
3600 |
Idle session lifetime. |
AI_MAX_SESSIONS |
1000 |
Maximum process-local sessions. |
AI_MAX_HISTORY_MESSAGES |
20 |
Conversation messages retained per session. |
AI_MAX_MESSAGE_CHARS |
8000 |
Maximum question length. |
AI_MAX_SCHEDULE_BYTES |
1000000 |
Maximum UTF-8 YAML snapshot size. |
AI_MAX_CONCURRENT_REQUESTS |
4 |
Maximum simultaneous provider streams. |
AI_ATTACHMENT_MODE |
images |
Use none to disable image attachments. |
AI_MAX_IMAGE_FILES |
4 |
Maximum images attached to one question. |
AI_MAX_IMAGE_BYTES |
5000000 |
Maximum bytes per image. |
AI_DOCUMENT_ATTACHMENT_MODE |
text |
Use none to disable document-to-text ingestion. |
AI_MAX_DOCUMENT_FILES |
4 |
Maximum documents attached to one question. |
AI_MAX_DOCUMENT_BYTES |
5000000 |
Maximum upload bytes per document. |
AI_MAX_DOCUMENT_TEXT_CHARS |
50000 |
Maximum extracted prompt characters per document. |
AI_MAX_PDF_PAGES |
100 |
Maximum pages per PDF. |
AI_MAX_XLSX_SHEETS |
20 |
Maximum worksheets per XLSX workbook. |
AI_MAX_XLSX_CELLS |
100000 |
Maximum rectangular cell span across an XLSX workbook. |
AI_MAX_XLSX_UNCOMPRESSED_BYTES |
50000000 |
Maximum total expanded XLSX archive bytes. |
AI_AUTH_TOKENS uses a JSON object such as
'{"institution-a":"first-key","person-b":"second-key"}'. IDs may contain
letters, numbers, underscores, and hyphens. They appear in administrative
session logs, while clients send only the key and never receive the ID. Remove a
pair and restart the service to revoke it. The legacy and identified settings
may coexist during migration.
Run in the development container¶
Build the existing all-in-one development image from the repository root:
docker build -f docker/Dockerfile -t nurse-scheduling:dev .
docker run --rm -it \
--name nurse-scheduling-dev \
--network=host \
--env-file docker/.env \
-v "$(pwd):/app" \
nurse-scheduling:dev
Start the AI backend inside the container:
Start the frontend from another host terminal:
The normal optimization backend is optional for this chat flow.
Run with Docker Compose¶
Both backend Compose variants start the AI service by default. Configure the AI
assistant block in docker/.env, then run from the docker/ directory:
Use compose.backend.memory.yml in the same command when running the
process-local optimization backend. The AI service itself remains process-local
in both variants and listens on port 8001 inside the Compose network.
Durable chat logging¶
Both Compose variants include PostgreSQL with the postgres-ai-data volume and
no published database port. As with Redis, the private service connection is
fixed in Compose and needs no setting in docker/.env. Native runs enable
logging only when AI_HISTORY_POSTGRES_URL is set.
Startup applies numbered SQL migrations transactionally. Chat sessions are
recorded on their first message. Each turn stores the user text, assistant text
(including partial answers), model, timestamps, attachment counts, available
token usage, and a completed, failed, cancelled, or stale status. Writes reuse a
server-generated turn UUID, also returned as message_id, to avoid duplicate
rows. Sending another HTTP request creates another turn.
The database stores the administrative credential ID when authentication is
enabled, never the owner cookie or bearer key. Raw attachments, extracted
document text, schedule snapshots, tool arguments/results, and reasoning are
excluded. User and assistant text can still contain staff information. Database
access is for operators only. No history-reading API or browser viewer is added.
Use a separate read-only database role for reporting. Question previews are
logged separately to stdout and have their own deployment log retention. Set
AI_REQUEST_LOG_ENABLED=false to stop logging chat text without silencing the
rest of that logger, and configure the nurse_scheduling.ai.requests logger to
redirect it. The built-in stdout handler is installed only when neither that
logger nor the root logger already has one.
Startup fails if configured storage is unavailable. A failed initial write
returns HTTP 503 before contacting the provider. A failed final write emits an
operator error log and reports history_saved: false in a successful done
event without discarding the live conversation. A process crash or final-write
failure can leave a row in running with no final answer. These rows indicate
incomplete logging, not a confirmed active request. There is no durable retry
queue or recovery of partial output after a process crash.
Retention runs on startup and hourly, deleting turns older than
AI_HISTORY_RETENTION_DAYS and expired empty session records. Configure backups
and their retention separately. Active sessions, schedules, and proposals remain
in memory, so stored history does not enable resuming a chat after restart.
Inspect chat history with pgAdmin¶
The optional pgAdmin service listens only on the backend host's loopback
interface. Start it from docker/ with the same Compose file and environment
file used by that deployment:
For a remote backend, forward the loopback port over SSH:
Open http://127.0.0.1:5050 and sign in with
[email protected] / pgadmin. Expand Nurse Scheduling, then
connect to AI chat history with database password ai_history. The server
definition is preloaded on every run.
Use Tools > Query Tool to inspect the newest turns:
SELECT
turns.started_at,
sessions.auth_credential_id,
turns.status,
turns.user_message,
turns.assistant_message,
turns.error_code,
turns.usage
FROM chat_turns AS turns
JOIN chat_sessions AS sessions ON sessions.id = turns.session_id
ORDER BY turns.started_at 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.
Use compose.backend.memory.yml in these commands for the process-local backend
variant. For staging, also pass its --env-file .env.staging option.
Run PostgreSQL integration checks against a test database whose role can create schemas. Each test creates and removes its own temporary schema:
cd core
AI_HISTORY_TEST_POSTGRES_URL=postgresql:///ai_history_test \
.venv/bin/pytest -q tests/test_ai_history.py
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.
HTTP API¶
| Endpoint | Purpose |
|---|---|
GET /health |
Process and service identity check. |
GET /ready |
Required configuration accepted at startup. |
GET /capabilities |
Enabled optional features and their public limits. |
POST /sessions |
Store a YAML snapshot and create a browser-owned session. |
POST /sessions/{id}/messages |
Stream one answer. Accepts JSON text or multipart text and attachments. |
Multipart requests use one message field, repeated images file fields, and
repeated documents file fields. Sessions are process-local. Use one AI
backend instance until shared AI storage is added.
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/.envprivate. Git ignores it, whiledocker/.env.examplecontains 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
SandboxBackendcontract 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 canonical storage into the sandbox.
- Treat shell commands and every sandbox file as untrusted. A sandbox can only return a candidate schedule. Trusted validation, proposal storage, revision checks, user approval, and canonical updates remain outside it.
- Use
AI_COOKIE_SECURE=0only for local HTTP. Set it to1when the public browser route uses HTTPS, even if NGINX uses internal HTTP to the container. Secure deployments set the owner cookie toSameSite=None; the CORS origin allowlist still limits which browser origins may make credentialed requests. - 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 configured provider. Use an approved provider and anonymize sensitive schedules when required.
- Accepted images are signature-checked and bounded before they are sent to the provider. Configure a matching request-body limit at the public reverse proxy.
- Accepted documents are bounded and checked for a matching supported filename extension, declared MIME type, and file signature where applicable. Text files require UTF-8. PDF extraction reads embedded text without OCR and rejects encrypted files. XLSX extraction disables external links, rejects encrypted or oversized archives, and uses hardened XML parsing.
- XLSX formulas are never evaluated. Extraction includes the formula text and the cached result last saved by a spreadsheet application. A missing cached result is marked as unavailable.
- 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 answer is not added to conversation history.
Troubleshoot local development¶
| Problem | What to check |
|---|---|
| Send fails immediately | Start the AI backend and request http://localhost:8001/health. |
| Provider unavailable | Check AI_PROVIDER_BASE_URL, AI_PROVIDER_API_KEY, and provider availability. |
| An attachment is rejected | Check its supported type and configured byte, text, page, sheet, and cell limits. Text documents must use UTF-8. Encrypted PDF and XLSX files are unsupported. |
| An answer stops early | Retry it. Cancelled and failed answers are not added to backend history. |
For a provider HTTP failure, search the AI backend log using the error ID shown
in the browser. If the logged response is a Cloudflare 520, inspect the
provider origin for an empty, malformed, or abruptly closed response. A 525
means Cloudflare could not complete TLS with the provider origin. Correlate the
logged timestamp and Cloudflare Ray ID with the provider proxy, tunnel, and
origin logs. See Cloudflare's 520
and 525
guidance.
Capability-gated controls¶
Optional controls stay hidden when the backend disables every attachment type
or capability discovery fails. Image mode defaults to images, while document
mode defaults to text. Confirm these were not changed to none, then compare
the direct and browser-facing responses:
Use the endpoint shown by the frontend's AI server control for the
browser-facing check. If local capability discovery fails, check that port
8001 is reachable and accepts the frontend origin. For production, check
/ai/capabilities through NGINX. When developing in a container, also
test the container address used by the browser. A loopback-only test can miss a
CORS failure or incomplete hydration.
Validate¶
Run the focused checks inside the development container:
cd /app/core
ruff check nurse_scheduling/ai nurse_scheduling/ai_serve.py \
tests/test_ai_basic.py tests/test_ai_documents.py tests/test_ai_provider.py \
tests/test_ai_sandbox.py tests/test_ai_sandbox_e2b.py \
tests/test_ai_sandbox_agent.py tests/test_ai_pi_bash.py tests/test_ai_pi_edit.py \
tests/test_ai_pi_read.py tests/test_ai_pi_write.py tests/test_ai_sandbox_tools.py
pytest -q tests/test_ai_basic.py tests/test_ai_documents.py tests/test_ai_provider.py \
tests/test_ai_sandbox.py tests/test_ai_sandbox_e2b.py \
tests/test_ai_sandbox_agent.py tests/test_ai_pi_bash.py tests/test_ai_pi_edit.py \
tests/test_ai_pi_read.py tests/test_ai_pi_write.py tests/test_ai_sandbox_tools.py
cd /app/web-frontend
bun run test -- \
src/app/experimental-ai/AssistantMarkdown.test.tsx \
src/app/experimental-ai/aiClient.test.ts \
src/app/experimental-ai/page.test.tsx \
src/components/Navigation.test.tsx
bun run build
bun run test:e2e:affected -- e2e/experimental-ai-basic.spec.ts