TreeMap
A free, open-source disk-space visualizer. Squarified treemap, duplicate hunting, disk-full forecasting, and cleanup that only ever moves files to the Trash.
翻译准备中
个人 GitHub 账号下公开仓库的所有者可以登录并验证所有权,自行管理介绍,无需管理员批准。
登录并生成验证文件,在 30 分钟内将其提交到此仓库的默认分支。其他分支或分叉仓库中的文件无效。然后在下方检查文件,即可无需管理员批准直接管理项目。请保留文件以维持管理权限。
如需登录,将前往登录页面。登录后请回到此处,再次点击所选的验证按钮。
在项目文件夹中运行 /nomorevibe verify,即可验证所有权并自行管理介绍。
一分钟完成验证Collaborative docs for people and AI agents. Open source (MIT).
翻译准备中
取自仓库 README
正在加载完整 README。 以下为已保存的摘录。
A real-time collaborative document editor for people and their AI agents, built with Yjs, TipTap, and Node.js. Multiple users and agents can edit the same document simultaneously with automatic conflict resolution. MIT licensed (see LICENSE).查看完整 README ↗A real-time collaborative document editor for people and their AI agents, built with Yjs, TipTap, and Node.js. Multiple users and agents can edit the same document simultaneously with automatic conflict resolution. MIT licensed (see LICENSE).
curl -fsSL https://squiredocs.com/install.sh | sh
This creates a squire-docs folder, downloads the release's compose.yml, squire wrapper, and settings file (checked against SHA256SUMS), starts Squire Docs with Postgres and Redis in Docker, waits until everything is healthy, and prints a one-time claim link as its last line. Open the link, click Continue, and you are signed in as the owner. Then connect your agent with claude mcp add --transport http squire-local http://localhost:3910/mcp and click Approve. That is two clicks and no passwords, email, or API keys. Requires Docker with Compose 2.24 or later.
AGENTS.md), which lists every command with its success condition./documentation/self-hosting on any instance../squire <command> in the install folder runs the in-container CLI, for example ./squire claim-link to sign in again or ./squire doctor.squire-data volume holds the generated secrets, including the key that encrypts stored API keys. docker compose down -v deletes it along with every document; docker compose down is the safe way to stop./chat) with conversation history sidebar and optional document side pane — toggle between document-centric and chat-centric layouts via the view-switch button in the header.md) file from the editor's tools menu (uses the same Yjs→Markdown serializer that powers version-history diffs). The REST route supports repo-friendly options: flavor=portable degrades HTML-only marks for clean GitHub rendering (underline→emphasis, highlight→bold, styled spans→plain text — declared per mark in the format registry), frontmatter=true prepends a self-describing squire: YAML block (docGuid, title, clock, exportedAt, lastModifiedBy, flavor, lossy list), and format=bundle downloads a zip with image assets under assets/<docSlug>/ and references rewritten to relative paths (bundle defaults: portable + frontmatter, both overridable). The route defaults to portable flavor (repo-friendly); pass flavor=squire for the full-fidelity dialect, which remains the internal canonical form. Task lists export as GFM - [ ]/- [x] and hard line breaks as trailing backslashes in every flavorcreate_document({ markdown }) or POST /api/docs/import), append or replace an existing document over REST (PUT /api/docs/:docId/import), or convert markdown to blocks inside a modify script with the fromMarkdown() sandbox helper. All surfaces share one import module; external images are fetched and rehosted server-side under an SSRF-safe policy, link hrefs are protocol-allowlisted, and a leading squire: frontmatter block is consumed defensively. See Markdown Import Surfaces.◇) diagram-as-code blocks or raw SVG (⬡) blocks from the toolbar that render live as you type. SVG blocks are sanitized at render time (DOMPurify with a strict policy — no scripts, event handlers, foreignObject, or external references; see shared/svg-sanitizer.mjs) so agent- or collaborator-authored markup can't execute script or phone home. Copying a diagram places a rasterized PNG on the clipboard so it pastes as an image into Google Docs/Notion, and the source round-trips back into an editable block on paste. Diagrams serialize to fenced code blocks (```mermaid, ```svg) for Markdown export and AI-agent editing.🖼), drag-and-drop, or paste (e.g. a screenshot). Bytes are stored in a dedicated S3 bucket; the document holds only a short app URL (resolved to a short-lived presigned URL on view), so the Yjs update log stays small. Images serialize to  for Markdown export and AI-agent editing. Optional — requires S3 config (see docs/dev.md); uploads are disabled if unset..md files attach too, and the assistant imports them into new documents via the import_markdown tool (content bypasses the model)modify tool (and undo/redo) display color-coded inline diffs directly in chat messages, with the changed words within a line emphasized GitHub-styleprefers-color-scheme live). The choice is saved per device (localStorage, key squire-theme) and applied before first paint, so there is no flash of the wrong theme on load — on every screen, signed in or out. Both the app chrome and the document canvas theme dark; author-set text/background colors render exactly as authored in both themes, Mermaid/SVG diagrams render on a light "media plate", and printing is always light. Color is centralized in a semantic token layer in client/src/index.css (light + dark realizations); a lint:colors gate (cd client && npm run lint:colors, also a Vitest test) keeps every themed surface literal-free/support) where users describe an issue and review their previous requests; submissions are saved to the support_requests table and emailed to the admin (reply-to set to the user)/documentation (no sign-in required) documenting the product across twelve pages. Sources are markdown files under documentation/ at the repo root, rendered to static HTML during the client build (client/scripts/build-documentation.mjs + render-documentation.mjs, markdown-it with html:false) and served like the marketing pages (explicit Express routes + a Vite dev middleware that renders on refresh). Terminology standard: "docs"/"documents" mean Squire documents; "documentation" means material about the product — never "docs". Reachable from the marketing header/footer and a "Documentation" item in the user menu. Design ground truth: design/product-documentation-site.md; user-facing feature descriptions belong on the documentation site, and this README narrows toward developer and operator material over timetsvector) combined with pgvector semantic/embedding search, fused via Reciprocal Rank Fusion. Content searches accept an optional updatedAfter recency filter (REST GET /api/docs?searchMode=content and MCP list_documents) applied inside each engine before ranking, so results and totals reflect only recently updated documents. Indexing is structure-aware: documents split along heading boundaries into ~600-token chunks (server/search/chunker.js), each carrying its heading_path, and every chunk's embedded text leads with title > h1 > … > hN so titles are semantically searchable. Multi-chunk documents additionally get short LLM-generated contextual preambles (server/search/contextualizer.js, Gemini 2.5 Flash via the provider registry) that are embedded AND keyword-indexed with the chunk — single-chunk documents get none, and preamble failures never block indexing. Search snippets always come from document-authored text, never generated preambles. The indexer is incremental: a content hash of the title + extracted body text gates embedding regeneration (formatting churn costs zero provider calls; a title change re-embeds because the title is part of the embedded text), and each chunk row records the embedding model that produced it so boot repair automatically re-embeds documents after a model upgrade (and re-chunks any remaining pre-018 fixed-window rows). Retrieval quality is measured by a first-class eval harness (npm run search:eval — see server/search/eval/); an optional LLM reranker exists behind SEARCH_RERANK and is off by default (a prior eval showed it lowered MRR at ~5.7 s/query)sk_sqd_ API tokens — including revoked and expired ones and whether a human or an agent minted each — plus how far they got in onboarding; read-only, and never exposes token hashes), and email notifications (sign-up, login, credit-limit, support requests, exceptions)modify toolOPENROUTER_API_KEY); Model Context Protocol (MCP) with OAuth 2.0 or API tokens for external AI agents. Per-provider behavior (model dispatch, key validation, web search, prompt caching) is defined in one place, server/api/ai-providers.jsTo work on Squire Docs itself (cloning is only needed for development; see CONTRIBUTING.md):
git clone <repository-url>
cd collaborative-editor
npm install
cd client
npm install
cd ..
Set up PostgreSQL database:
createdb collab_db
Or using psql:
psql -U postgres -c "CREATE DATABASE collab_db;"
npm run migrate
Optional convenience script: If you want to automatically create the database and run migrations:
npm run init-db
To run both the server and client in development mode:
npm run dev
This will start:
http://localhost:3001http://localhost:5173Open http://localhost:5173 in your browser to use the editor.
Sandbox option: the Minikube + Mutagen dev environment can be driven by a one-command CLI,
collab-devcontainer(in-repo atdevcontainer/; rundevcontainer/installonce), which runs theapp-devpod from a hardened dev image (Node 22 + Claude Code +gh+ build tools). It's a convenience front-end over the same substrate documented in docs/dev.md, not a replacement.
This is a single-developer personal project, so it deliberately skips the ceremony of a team workflow. There is no pull-request or code-review process — ordinary changes are committed straight to main and deployed to prod from there (see Deploy Scripts). Branches are used only when they earn it: exploratory spikes, larger multi-step efforts, or work risky enough to want isolated until it's ready. Default path for a normal change: run the tests, commit to main, deploy.
Larger, design-driven efforts follow the design pipeline (.claude/skills/the-pipeline/SKILL.md): design docs are authored collaboratively in Squire itself (dogfooding), exported to design/ via node design/sync.mjs as ground truth, then spec-kit agents spec/plan/implement in parallel worktrees with a serial merge queue and post-merge review. The project constitution lives at .specify/memory/constitution.md.
Backend only:
npm run server
Frontend only:
npm run client
npm run build
npm start
The server will serve the built frontend from client/dist and handle WebSocket connections.
npm run build also renders two static markdown-driven sites into client/dist:
/documentation): sources in documentation/*.md, rendered by
client/scripts/build-documentation.mjs, served by server/documentation-routes.js./blog): sources in blog/*.md, rendered by client/scripts/build-blog.mjs,
served by server/blog-routes.js. Each post is a markdown file with slug, title,
description, date, and author frontmatter; the index lists posts newest-first.Both reuse the shared marketing header/footer chrome (marketing.css) and are served in dev
by Vite middleware (client/vite.config.js), so a content edit shows on refresh with no build.
The hosted service at squiredocs.com runs on a dedicated single-node k3s cluster on
AWS behind CloudFront. Its operations files (OpenTofu, the production Kustomize
overlay, the observability manifests, SOPS-encrypted secrets, the deploy scripts,
and the operator guide) live in a private operations repository, not in this one.
That repository is laid over a checkout of this one at deploy time, so the shared
pieces stay here: k8s/base (the app, Postgres, Redis, and the backup CronJob),
the Dockerfile and Dockerfile.backup, and script/backup-postgres.sh. Design
ground truth is
design/infrastructure-and-environments.md.
To run your own instance, see the self-hosting section below; you do not need any of the hosted service's operations files.
Local development uses Minikube — see docs/dev.md.
# 1. Start minikube with sufficient resources
minikube start --cpus=10 --memory=12288 --disk-size=40g --driver=docker
# 2. Create storage classes and label nodes
./script/setup-minikube.sh
# 3. Deploy PostgreSQL
./script/postgres-deploy.sh
# 4. Build the app image in minikube's Docker context
./script/builddockerdev.sh
# 5. Deploy the application (Redis, secrets, app, etc.)
./script/deploy.sh
To restore a pg_dump backup into a cluster's Postgres pod, stream it in:
gunzip -c collab-backup.sql.gz \
| kubectl exec -i deploy/collab-postgres -n collab -- psql -U postgres -d collab_db
| Script | Purpose |
|---|---|
script/setup-minikube.sh | Creates storage classes and labels minikube nodes (local dev) |
script/postgres-deploy.sh | Deploys PostgreSQL (secret, PVC, deployment, service) for minikube |
script/builddockerdev.sh | Builds the Docker image in minikube's Docker context |
script/deploy.sh | Legacy shared-node/GKE deploy (envsubst-based; kept for the rollback window) |
script/backup-postgres.sh | pg_dump backup script baked into the backup image and run by the CronJob |
deploy.sh does not deploy PostgreSQL — run postgres-deploy.sh first (minikube only)collab:latest (built locally)docs/dev.md for the full development environment guide (Mutagen sync, port-forwarding, etc.). Mutagen sync is almost always running and reliable — you can generally trust local changes are synced to the pod without verification.PORT: Server port (default: 3001)DATABASE_URL: PostgreSQL connection string (alternative to individual DB config)DB_HOST: PostgreSQL host (default: localhost)DB_PORT: PostgreSQL port (default: 5432)DB_NAME: Database name (default: collab_db)DB_USER: Database user (default: postgres)DB_PASSWORD: Database password (default: postgres)APP_URL: The instance's public origin (feature 058). Defaults to CLIENT_URL, else http://localhost:${PORT:-3001}; a path or trailing slash is dropped, and an invalid value fails boot. It decides the cookie Secure flag (set only when APP_URL is https), the redirect and callback defaults below, welcome-doc URLs, and the base URL the MCP tools fall back to.SQUIRE_HOSTED: true turns on everything that only makes sense for squiredocs.com: marketing, pricing, blog, about, security, privacy and terms pages; the Google Analytics/Ads tag and CSP sources; signup AI credits and the credit meter; admin sign-up and login notification emails; the beta welcome email and its admin control; the production self-test reset endpoint. Unset (the default) means a self-hosted instance, and any value other than true or false fails boot: those routes return 404 and / serves the app. The aws-prod overlay, the minikube overlay, and the app-dev pods set it to true.SQUIRE_MODE: local (the default) or team (feature 059). Local mode has one owner, closed sign-up, and sign-in only through one-time links minted by the squire CLI. Team mode uses the configured sign-in providers (today Google, plus the dev faucet when dev endpoints are mounted) and refuses to boot with none. Any other value fails boot. The base deployment, the aws-prod overlay, both dev manifests, .env.example, and the Jest setup all set team, so the hosted service and development behave as before.SQUIRE_DATA_DIR: Data directory for generated secrets (secrets.json) and local image bytes (images/) (default: /data)MIGRATE_ON_BOOT: When true (the image default), the entrypoint runs migrations under a Postgres advisory lock before the server loads. The Kubernetes overlays set false and keep the Job.Example using connection string:
PORT=3001 DATABASE_URL=postgresql://user:password@localhost:5432/collab_db npm start
Example using individual config:
PORT=3001 DB_HOST=localhost DB_PORT=5432 DB_NAME=collab_db DB_USER=postgres DB_PASSWORD=password npm start
The image runs node script/entrypoint.js (the Dockerfile CMD, so the migration Job's npm run migrate still overrides it). In order it loads .env, starts OpenTelemetry, validates the instance configuration (server/instance-config.js), loads or generates secrets into process.env (server/boot/), runs migrations under an advisory lock when MIGRATE_ON_BOOT=true, and only then requires server/index.js. The order is load-bearing: server/auth/jwt.js, server/mcp/auth/jwt.js, and server/crypto.js check their secrets when first required and throw in production if they are missing. The image sets NODE_ENV=production and its health check probes /ready. Image bytes go through the storage facade in server/image-storage/ (server/s3-images.js is gone); hosted-only routing lives in server/web-routes.js and the app shell (with the injected hosted flag) in server/app-shell.js. Design: design/self-hosting-local-mode.md.
user_identities, one row per (issuer, subject) pair, never through email. Google rows were backfilled from users.google_id, which is now nullable and legacy (new rows never write it); a compatibility trigger in migration 1799830000000_add-user-identities-and-signin-links.js creates identities for rows still written with google_id during a rolling deploy. The dev faucet's dev-test- users map to the dev issuer. An unknown identity whose email matches an existing account is refused with account_exists, never linked. server/auth/post-auth.js holds the shared post-sign-in path every method uses (cookies, the single auth_events row, provenance, onboarding, the feature 031 auto-issue gate).GET /auth/providers lists the enabled sign-in methods (server/auth/providers.js); the sign-in and consent pages render from it and fall back to Google if it cannot be fetched. In local mode they show the CLI instruction instead.signin_links stores only a SHA-256 hash, a target (claim or a user id), a 15-minute expiry, and used_at. The link is ${APP_URL}/claim#<token>: the token travels in the fragment, so it never reaches access logs or Referer. The claim page calls POST /auth/signin-link/peek (read-only: valid, and whether it creates the owner or signs someone in), and Continue posts to POST /auth/signin-link, which spends the link and runs the normal post-sign-in redirect (JSON when asked). Owner creation runs in one transaction that takes the users lock and fails if any user exists; the owner id is recorded in app_settings (server/auth/instance-owner.js). An unclaimed local instance prints a claim block in plain lines just before it reports ready. Link sign-ins record signin_link as the sign-in source. No HTTP or MCP endpoint mints links; the /auth per-IP limiter covers both endpoints.squire CLI (bin/squire.js, on PATH in the image; modules in server/cli/): doctor [--json] (probes /ready, reports mode, APP_URL, optional features, owner; --json carries a failed list), claim-link [--name] [--email], login-link --email, mode, and token create --name <agent> (an token with read and write scopes, 30-day default expiry, written to a 0600 file under the data directory and never printed without ). In the image: .squiredocs/squiredocs publishes compose.yml, the squire wrapper, env.example (GitHub renames dot-leading asset names; install.sh saves it as .env.example), and SHA256SUMS, all pinned to the release's image tag. Sources live in distribution/self-host/; distribution/self-host/release.mjs stamps them. The repository name is one constant mirrored in four files and pinned by a drift test.ghcr.io/squiredocs/squiredocs, multi-architecture (amd64 and arm64), tags X.Y.Z plus latest, which moves only to the highest X.Y.Z release so a backport never moves it back (per-architecture staging tags X.Y.Z-amd64 and X.Y.Z-arm64 back the manifest). Releases are cut by pushing a vX.Y.Z tag; .github/workflows/release.yml builds both architectures on native runners, smoke-tests each (healthy stack, squire doctor --json, the isolate sandbox), runs an agent-playing job (installs with install.sh from the run's own assets, redeems the claim link, drives the OAuth chain with test/first-run/oauth-chain-driver.mjs --signin-link, ends with a modify call), and only then pushes the tested image tarballs and attaches the assets. Only the publish job gets write permissions, run steps use bash with pipefail, and every action is pinned to a commit SHA. It refuses to publish from any repository other than squiredocs/squiredocs. The hosted service builds its own image separately.install.sh (POSIX sh, never prompts): flags --dir PATH (default ./squire-docs), --version X.Y.Z (default latest), --name, --email; SQUIRE_INSTALL_ASSET_URL overrides the asset source (used by CI); SQUIRE_PORT, when set, is validated and saved to .env. Rerun on a folder that already holds an install (it has compose.yml), it resumes: it downloads, writes, and removes nothing there, runs the same up -d --wait, squire doctor, and claim-link steps, and words the final message for a claim or, on a claimed instance, a sign-in link (detected from claim-link's stderr). It never upgrades; it prints the upgrade command (set SQUIRE_VERSION in .env, then docker compose pull && docker compose up -d --wait) only when the latest release is newer than the installed one, and explains how to switch when differs from it. It refuses any other non-empty folder. When fails on an image download (registry , , , rate limits, network errors), it names the image and the registry's reason and says to rerun. gets and a random , so same-named install folders never share containers or volumes. The whole script runs from on its last line, so a truncated download runs nothing. Progress goes to stderr; stdout carries only the claim link.The application includes OAuth 2.0 support for AI agents via the Model Context Protocol (MCP). These secrets are used to sign JWT tokens for agent authentication.
For local development, generate secrets and add them to your .env file:
./script/generate-mcp-secrets.sh
This will output environment variables to add to your .env file:
MCP_JWT_SECRET: Signs agent access tokens (JWT)MCP_REFRESH_SECRET: Signs refresh tokensMCP_AUTH_CODE_SECRET: Hashes authorization codesFor Kubernetes deployment, generate a secret YAML file:
./script/generate-mcp-secrets.sh --k8s
This creates k8s/mcp-auth-secret.yaml which is automatically applied during deployment. The generated file is gitignored for security.
Manual Kubernetes secret creation:
kubectl create secret generic mcp-auth-secret \
--from-literal=MCP_JWT_SECRET=$(node -e "console.log(require('crypto').randomBytes(32).toString('hex'))") \
--from-literal=MCP_REFRESH_SECRET=$(node -e "console.log(require('crypto').randomBytes(32).toString('hex'))") \
--from-literal=MCP_AUTH_CODE_SECRET=$(node -e "console.log(require('crypto').randomBytes(32).toString('hex'))") \
-n collab
See docs/mcp-auth-production-plan.md for complete MCP OAuth documentation.
The frontend connects to the WebSocket server at the /s path. By default, it connects to ws://localhost:3001/s. To change this, set the VITE_WS_URL environment variable when building:
VITE_WS_URL=ws://your-server.com/s npm run build
The server is instrumented with vendor-neutral OpenTelemetry (feature 014):
console.* call emits one line of JSON to stdout
({ level, time, msg, ... }). Lines emitted inside an HTTP request also carry
trace_id / span_id for correlation. No existing log call site was rewritten
— a non-throwing console shim (pino) does this globally.OTEL_EXPORTER_OTLP_ENDPOINT when set. With no endpoint configured the whole
stack is inert — no exporter, no crash, no startup delay, no log noise —
so dev, CI, and the test suite run unchanged with no Collector.The Collector, monitoring node (OpenObserve), dashboards, and alarms are the
observability platform (feature 013) — see design/observability-and-telemetry.md.
Their manifests live in the private operations repository.
The collaborative editor binding must never destroy remote content. Stock
@tiptap/y-tiptap has two self-repair paths that can delete shared content
from a merely-watching viewer's browser and attribute the deletion to that
viewer (confirmed incident, 2026-07-18): the render catch-blocks delete the
shared element/text on a render failure, and an unguarded selection-restore
abort plus an ungated editor→Yjs diff can "correct" the shared doc back to a
stale view. Three defenses (design ground truth:
design/collaboration-core.md, 021 amendments + Addition):
@tiptap/y-tiptap is exact-pinned to 3.0.7
and patched via patch-package: client/patches/@tiptap+y-tiptap+3.0.7.patch
is applied by the client postinstall (patch-package --error-on-fail, so a
bump without a rebased patch fails the install loudly). Five SQUIRE-021:
sentinel sites: render failures attempt a createAndFill stand-in then
log-and-skip (never mutate the shared doc; bounded once-per-element logging +
a server-side skip report); selection restore is exception-guarded with a
clamped near-fallback; the editor→Yjs write-back only runs when the
transaction actually changed the doc; detected divergence re-renders FROM
Yjs; and a tracked-skip registry makes skipped nodes invisible to the PM→Y
diff so a later legitimate edit can't delete them "through the front door".
A drift-guard test (client/src/__tests__/binding-patch-guard.test.js) pins
the version, the sentinels, and the patch file (SC-007).app_settings.collab_binding_hardening
(absent = ON), flipped with PUT /api/admin/settings/collab-binding-hardening {"enabled":false} and delivered to browsers by GET /api/client-config →
globalThis.__SQUIRE_COLLAB_HARDENING__ (read live; at most a page refresh
to take effect; fetch failure fails safe to hardened). The guardrail and the
quarantine layer are independent of this flag.enableContentCheck/onContentError
on the editor: a whole-doc schema mismatch (doc uses node types this bundle
lacks) disables collaboration, makes the editor read-only, and shows a
refresh banner instead of letting anything "repair" shared content.POST /api/collab/render-skip-report (content-free:
node type + error class + count only), which logs a [CollabSkipReport]
line and increments the collab.render_skip.reports OTel counter.server/collab-guardrail.js intersects
its Yjs delete set with item ranges inserted by agent-attributed rows
younger than ~10 s; a match raises an exception-notifier alert (doc, human,
agent, DB clock range — feature-016 invertible) with per-(doc,user)
suppression. It never blocks, delays, or modifies updates.getYDoc detects clock gaps within the fetched
rows (a read racing a mid-commit row), retries the full fetch briefly
(≤2 retries, ~100/300 ms), then serves as-is with a
log line; gap-free reads are unchanged.Env knobs (all optional): GUARDRAIL_FRESHNESS_SECONDS (default 10),
GUARDRAIL_SUPPRESSION_MS (default 300000), COLLAB_READ_GAP_RETRIES
(default 2), COLLAB_READ_GAP_RETRY_DELAYS_MS (default 100,300),
COLLAB_RECONCILE_INTERVAL_MS (default 30000),
RL_COLLAB_SKIP_PER_MIN (skip-beacon rate limit, default 30).
A role on a document can come from two channels: a direct share, or membership in the space the document lives in (a document belongs to at most one space). Your effective role is the stronger of the two, computed in one place (the document_access view), so the list, search, realtime, and agent surfaces always agree. A space-owner member holds the Owner role on every document in their space; deleting a document still requires the Owner role, and "owned by me" filters and counts always mean a direct owner share, never space passthrough.
See docs/permissions.md for detailed permission documentation.
The editor maintains a complete version history of all document changes, enabling review and restoration of previous states.
Auto-Versioning: Consecutive edits within a 5-minute window are automatically grouped into a single version. When editing pauses for more than 5 minutes, a new version begins. This prevents every keystroke from creating a separate version while preserving meaningful checkpoints.
Named Versions: Users can explicitly name any version (e.g., "Final Draft", "Before Refactor"). A named version is a pure clock-range label — content is always rebuilt by replaying the update log (the single source of truth; no cached snapshots), so a named version can never diverge from the history it labels.
Write ordering & timeline cost: Updates are persisted through a per-document ordered queue with a Postgres advisory-lock backstop, so clock order always matches the order edits were made (a version coordinate can never silently contain a later edit but miss an earlier one). Each update's "meaningful vs. noise" classification is computed once at write time and stored (yjs_updates.meaningful), so building the timeline reads rows instead of replaying the whole document per request; server/scripts/backfill-meaningful-classification.js backfills historical rows idempotently.
Author Tracking: Each edit records the user (or AI agent) who made it. Versions display all contributors with deterministic avatar colors. Agent edits are tagged with the agent name for transparency. Content recorded as sync-resupplied (the via_sync marker, feature 038 onward) is not credited to whichever client happened to relay it: the server recovers the real author by matching the update's Yjs origin clients against earlier directly-attributed edits in the same document, and content whose author cannot be established that way is shown as Synced content rather than being attributed to a guess. Rows written before the marker existed are unmarked and fall outside this recovery. Author lists are computed strictly from each version's own clock range: naming a version that splits an editing session never spreads one range's contributors onto another, and the drill-down applies the same meaningful-update filter as the timeline so its rows and counts always agree with the parent view.
Honest failure states: A failed history or diff load renders as an error with a retry button — never as the "no version history yet" empty state. A failed action (restore, name, rename, remove name) is reported separately, naming the action that failed, so it is never mistaken for a failed load and never offers a retry that would only refetch the timeline; it stays until dismissed rather than being cleared by the next refresh. A failed drill-down is reported on its own row, with its own retry. On the server side, a database failure during document load refuses the collaboration bind (clients are closed with code 1013 and reconnect) instead of serving an empty document as if it were new.
The version history panel uses a three-level hierarchy:
While the panel is open it refreshes every 10 seconds (paused for hidden tabs, resumed immediately when the tab comes back, and skipped while a previous refresh is still in flight), so collaborators' new versions appear without reopening it. The current selection is reconciled against each refresh — after a rename or an auto-version re-split, the header, contributor list, and restore gating always describe the version as it now exists, and expanded drill-downs re-fetch rather than showing stale ranges. Refreshes driven by the poll are silent: the preview keeps the last-good content on screen until the new one is ready instead of flashing a loading placeholder while a collaborator types, and a change to a version's contributors alone (for example a Synced content entry that later resolves to a person) updates the contributor list and any expanded rows without blanking them. Drill-down results are cached against the clock range they answer, so a range that moves under a version never inherits the previous range's edits or authors.
Restore is non-destructive: restoring a previous version creates a new version with that content rather than discarding subsequent history. All users see the restored content in real-time (delivered cross-instance, never silently skipped). The restore delta is computed on a throwaway document seeded from the best trusted state (the live document when this instance has it loaded, otherwise the persisted log), stored before it is broadcast, and then applied — so a crash mid-restore can only leave the restore committed but not yet echoed, which the next load replays, rather than shown to everyone and never recorded. An edit landing during the store merges with the restore instead of being replaced by it. Restoring or undoing a document nobody has open no longer loads it permanently into server memory. A restore over MCP (restore_document_version) is recorded under the acting agent's identity like any other tracked edit, so that agent's undo can invert it. A restore from the web UI is attributed to the person who performed it and is not an undo target on any surface — it is reverted the same way it was made, by restoring again (nothing was discarded, so the version you came from is still in the history).
Version history includes visual diff highlighting to show what changed between versions:
shared/diff/word-diff.js) and the strong diffInsertWord/diffDeleteWord marks are stamped by post-processing the parser output (server/diff/apply-word-marks.js). Oversized or slow regions degrade gracefully to the line-level marks, for the whole region, on both surfaces. The chat diff cards use the same word segmentation, over the same unit — a whole replace region, segmented once, then split back across rows (computeLineWordSegments) — so the two surfaces emphasize the same words for the same edit. (They previously diverged: chat paired deleted and added rows positionally, so a rewrite with a line inserted at the top compared every row against the wrong partner — lighting up unchanged text while genuinely new rows, beyond the shorter side's length, got no emphasis at all. The shared-segmentation claim was ratified in 022 and had silently regressed; it is enforced by a parity test.) Segments travel to the browser as an additive inlineSegments payload field rendered as emphasized spans, and are stripped from every model-bound copy of a tool result — they are browser-only presentation data the model can already derive from the -/+ rows. Chat diff cards also strip markdown hard-break markers before diffing (stripHardBreakMarkers in server/mcp/diff-utils.js): the trailing backslash the canonical serializer emits for a hard line break is syntax, not content, so it never renders in a card — while genuine backslashes (inside code/mermaid/svg fences, block-final, or literal doubles) are preserved exactly, per CommonMark's own hard-break grammar. Legacy persisted diff payloads predate the cleanup and render as storedMarkdown parsing & format pipeline: The markdown → ProseMirror parser and the format registry live under shared/ (client- and server-safe, no Node built-ins), so one grammar can serve the server and, in future, editor paste. markdownToPm(markdown, diffMark, { strict }) has two modes:
<br> — and GFM task lists parsed into real taskList/taskItem nodes with checked state). It enforces a never-lose-content rule: any input parses to a schema-valid document without throwing, and every word of the input survives — worst case, unknown constructs degrade to literal-text paragraphs. This is what import surfaces and agent-authored markdown use.---) keep parsing exactly as before; the Redis diff cache (CACHE_VERSION) stays valid.Inline-format knowledge (delimiters, HTML tags, style props) lives only in shared/format-registry.js, so adding a mark extends the serializer, both parser modes, and the round-trip suite with zero parser edits. The full supported-grammar and degradation-ladder tables are in specs/001-general-markdown-parser/data-model.md.
Import is the inverse of export: it turns untrusted markdown into rich document blocks through a single server-side module (server/markdown-import.js), so every surface enforces the same invariants. Four surfaces wrap it:
create_document({ markdown }) — one call creates a fully populated, titled, attributed document. Title precedence: explicit title → frontmatter squire: title → first heading → Untitled (a title-donor heading stays in the body).POST /api/docs/import — same one-call create over HTTP with an sk_sqd_ token (owner = the acting user).PUT /api/docs/:docId/import?mode=append|replace|sync — import into an existing document (default append; replace swaps the body in a single attributed undo step; sync pushes edited repo files back as CRDT operations — see Two-Way Sync below). Requires the editor role.fromMarkdown(md) sandbox helper — convert markdown to detached nodes inside a modify script and place them with the positioning primitives you already use (same contract as cloneBlocks).An import's content change is delivered to every instance, not just the one that handled the request: a document reached through getSharedDoc has no Redis handler attached (that is wired lazily by the WebSocket connection handler), so the import publishes the update itself when nothing else did — guarded so an instance that already relays the document never double-sends, and a no-op when Redis is disabled. Agent-authenticated imports also announce presence while they run (see AI Chat Assistant).
Both REST routes return a verification receipt: markdown is the canonical re-export of the post-import document (?flavor=portable|squire, default portable), so fidelity checking is an exact comparison. Pass ?frontmatter=true to get the receipt stamped with the squire: frontmatter block (docGuid, clock) — written back over the source file it makes the document born syncable, a valid mode=sync baseline with no separate export step (design proposal §1.2.1, 2026-07-14).
Security posture (constitution Principle V — import is an untrusted ingestion surface): every surface requires an authenticated principal (documents:write for scoped tokens; PUT also requires editor role); markdown bodies are capped at 5 MB and rejected before parsing; unsupported content types → 415. Content is parsed only via feature 001's tolerant parser (never executed as HTML beyond the registry whitelist). Link hrefs are protocol-allowlisted (http/https/mailto/app-relative — javascript:/data:/vbscript:/file: lose the link mark, text kept).
Image fetch-and-rehost policy: external http(s) image references are fetched server-side and rehosted into the app's image storage (attributed to the acting user), then rewritten to app URLs — so imported documents stay self-contained and never make viewers' browsers load attacker-chosen URLs. The fetch is SSRF-safe (server/image-rehost.js): http/https only; the resolved address of the initial request and every redirect hop is validated against private/loopback/link-local/metadata/CGNAT/unique-local/multicast ranges (v4, v6, and IPv4-mapped/NAT64 forms), with the connection pinned to the validated IP (no DNS-rebind window); 3-redirect cap, 10 s timeout, the existing 15 MB byte cap enforced while streaming, and the existing png/jpeg/gif/webp MIME allowlist. Up to 20 unique images are fetched per import (deduplicated). Every failure — SSRF block, size, type, budget, or storage unconfigured — degrades to a plain link and is itemized in the response images report rather than failing the import. data: image srcs are rejected outright (dropped to alt text). After any import the stored document contains zero external and zero data: image srcs.
Frontmatter: a leading --- YAML block is consumed defensively — recognized squire: fields are used (currently title), unknown keys are ignored, and any non-squire: keys are preserved as a leading fenced yaml code block so nothing is lost. Malformed YAML is treated as ordinary content; frontmatter never fails an import and never executes.
PUT /api/docs/:docId/import?mode=sync is the write half of the repo round trip: a document exported to a Git repo (with frontmatter=true) can be edited by humans or tooling (e.g. spec-kit) and pushed back. Rather than clobbering the document, its edits replay as native CRDT operations anchored at the export-time baseline clock — exactly as if the repo editor were a collaborator who went offline at that clock, edited, and reconnected. This preserves the CRDT identity, marks, undo history, and attribution of every untouched block (constitution Principle IV); only the blocks the file actually changed are touched, character edits stay in-place, and block replacement is the bounded last resort for structural changes.
squire: clock frontmatter (an explicit ?baselineClock= param overrides). The reconstructed CRDT state at that clock is forked under a synthetic client identity; the file is aligned against the fork's canonical markdown block by block, and each resulting hunk is replayed on the fork. The fork's update-since-baseline is applied to the live document through the normal update path.overlaps flags for after-the-fact review in version history; they never block or alter the push. Application is order-independent and retried pushes are idempotent (deterministic replay + CRDT dedup), so at-least-once delivery is safe.{ clock, markdown, overlaps, blocksChanged, operations, converged } — markdown is a canonical re-export of the post-push document with refreshed frontmatter, which the tool rewrites its local file from so it is immediately a valid next baseline. A byte-identical, formatting-only, or lossy-degradation-only push is a true no-op (noop:true): zero operations, no stored update, no version entry — so pull→push loops never generate phantom edits. This round-trip invariant (import(export(doc)) ≡ no-op) is enforced continuously in CI.Which tool, not just which channel (feature 054): the channel rule says content that already exists as bytes outside the model travels the REST byte channel rather than being retyped through tool parameters. It never said which tool to reach for, and that is the question agents were getting wrong — whole-file syncing a document someone had open in order to change one sentence. Every surface that teaches the channel rule now also states the split: whole-file byte-channel sync is for authoring, importing, and bulk updates; XPath-targeted modify is for small targeted edits, and is the preferred tool when the document is being actively edited or a specific node is damaged. The split is pinned by tests on the three budgeted description surfaces (SERVER_INSTRUCTIONS, modify.description, import_markdown_file.description), since a tight byte budget makes it the sentence a future trimming edit would drop first.
The push is API-first (any client can implement pull/push against it); see get_tool_documentation({ tool: "export_api" }) for curl recipes.
Nested Sub-versions: Optionally drill down into individual edit groups within a version using the includeSubversions parameter. Sub-versions use a 10-second grouping threshold for granular change tracking.
Time-based Filtering: Filter versions by date/time range using ISO 8601 timestamps:
// Get versions from a specific date
await list_document_versions({
docGuid: "abc-123",
since: "2024-01-15T00:00:00Z"
});
// Get versions in a date range
await list_document_versions({
docGuid: "abc-123",
since: "2024-01-01T00:00:00Z",
until: "2024-01-31T23:59:59Z"
});
| Endpoint | Description |
|---|---|
GET /api/docs/:docId/history | Get version timeline |
GET /api/docs/:docId/history/updates?from=X&to=Y | Get individual updates in clock range |
GET /api/docs/:docId/history/clock/:clock | Get document state at specific clock |
GET /api/docs/:docId/versions/:versionId | Get content at named version |
POST /api/docs/:docId/versions | Create named version |
POST /api/docs/:docId/restore | Restore to previous version |
GET /api/docs/:docId/export?format=markdown | Export document as a Markdown file download. Options: flavor=squire|portable (default portable), frontmatter=true|false (default off), format=bundle for a zip of markdown + image assets with relative references (bundle defaults to portable + frontmatter, overridable) |
POST /api/docs/import | Create a new document from a text/markdown body (owner = acting user) |
PUT /api/docs/:docId/import?mode=append|replace | Import markdown into an existing document (editor role required) |
PUT /api/docs/:docId/import?mode=sync | Push edited repo file back as CRDT ops anchored at its baseline clock (two-way sync); returns clock + canonical re-export + advisory overlaps |
POST /api/spaces | Create a space (creator becomes its owner) |
GET /api/spaces | List my spaces with member count and my role |
GET /api/spaces/:id | Space detail: members, pending invites, document count (members only; non-members get 404) |
PATCH /api/spaces/:id | Rename the space (owner) |
DELETE /api/spaces/:id | Delete the space; its documents revert to personal (owner) |
POST /api/spaces/:id/members | Invite by email at a role (any member, at up to their own role; unknown emails become pending invites) |
PUT /api/spaces/:id/members/:userId |
GET /api/docs also accepts space=all|personal|<spaceId> to scope the document list.
The import routes require documents:write, accept text/markdown/text/plain bodies capped at 5 MB, and return an itemized image report; see Markdown Import Surfaces or get_tool_documentation({ tool: "export_api" }).
A built-in chat panel lets users interact with an AI assistant directly inside the app. The assistant can read, edit, and manage documents using the same MCP tools that external agents use.
app_settings table. Its precedence is: BYOK selection → admin-pinned per-user override (users.chat_model_override) → admin shared-default selection → AI_CHAT_MODEL env var → DEFAULT_MODEL_KEY code constant (Claude Sonnet 5.5). The per-user override applies only on the shared-key path — an active BYOK choice still wins, since that user is billing their own key. Eligibility is derived, never listed: a model is selectable as the shared default exactly when its provider has a shared server key configured (serverKeyEnv/hasServerKey in server/api/ai-providers.js) — Anthropic, Google, and (when OPENROUTER_API_KEY is set) the OpenRouter gateway qualify; OpenAI and direct z.ai are BYOK-only and excluded. A stored selection (or AI_CHAT_MODEL value) whose provider later loses its key degrades at resolution to the built-in default with a logged warning instead of running an unauthenticated client. The built-in default itself follows the configured keys (Claude Sonnet 5.5, else Kimi K3 via OpenRouter, else Gemini 3.5 Flash); with no server key at all the turn is rejected as assistant_not_configured (below). Supported models:
claude-opus — Claude Opus 4.8 (requires ANTHROPIC_API_KEY)claude-sonnet — Claude Sonnet 4.6 (requires ANTHROPIC_API_KEY)claude-sonnet-5, claude-sonnet-5-5 — Claude Sonnet 5 and Sonnet 5.5 (requires ANTHROPIC_API_KEY; claude-sonnet-5-5 is the built-in default)claude-opus-5, claude-opus-5-5 — Claude Opus 5 and Opus 5.5 (requires ANTHROPIC_API_KEY)claude-fable-5, claude-fable-5-1 — Claude Fable 5 and Fable 5.1 (requires ANTHROPIC_API_KEY). Sonnet 5.5, Opus 5.5 and Fable 5.1 reject replayed thinking blocks once earlier history has changed, so prior-turn reasoning is not resent to them (shouldStripReasoningFromHistory in chat-models.js)claude-haiku — Claude Haiku 4.5 (requires ANTHROPIC_API_KEY)gemini-2.5-flash — Gemini 2.5 Flash (requires GOOGLE_GENERATIVE_AI_API_KEY)gemini-2.5-pro — Gemini 2.5 Pro (requires GOOGLE_GENERATIVE_AI_API_KEY)Each user has a monthly AI credit allowance (default: $10.00). Usage is tracked per-request based on token counts from the AI provider, with costs computed from official pricing. When a user exceeds their allowance, chat requests return a 429 and the UI shows a clear error banner. Usage resets automatically at the start of each calendar month.
ai_credit_cents on the users table (overridable per user)ai_extra_credits table — admins can grant bonus credits that supplement the monthly allowance and persist until depleted or expiredai_usage_log table records every request with model, token counts, and costserver/ai-usage.js)/chat): Full-page chat with conversation history sidebar and optional document side panePanel position and size preferences are persisted to localStorage, as is the chat text-size scale (--chat-font-scale, adjustable via the header A−/A+ stepper).
A swap-arrows button in the header switches between document-centric and chat-centric layouts. Context is preserved across toggles:
Client Server
────── ──────
AiChatContext.jsx server/api/chat.js
└─ useAiChat() (AI SDK) ├─ streamText() with configurable model
│ ├─ Model registry (chat-models.js)
▼ ├─ MCP tools via chat-tools.js
└─ Agent presence (cursor/highlights)
AiPanel.jsx ── AiChatMessages.jsx (document-centric)
└─ AiChatInput.jsx
ChatPage.jsx ── AiChatHistory.jsx (chat-centric)
├─ AiChatBody.jsx
├─ AiChatInput.jsx
└─ DocSidePane.jsx (optional)
The chat endpoint builds a synthetic agent token from the user's session, so tool calls execute with the user's permissions and show up as agent activity in the editor (cursors, highlights). Agent presence is deduplicated cluster-wide via a Redis presence claim (server/mcp/presence-claim.js): only the instance executing the current tool call announces the agent in awareness — the claim follows the work across instances, and without Redis (single-instance deployments) behavior is unchanged.
REST imports announce presence too (server/import-presence.js). An agent-authenticated PUT /api/docs/:docId/import opens the same presence session, right after the editor-role gate and before parsing or the image pass, so a watching human sees the agent arrive before the content changes, sees a temporary selection over the changed blocks, and sees it leave on its own. It uses the same synthetic-token mechanism, keyed on the caller's own agent id, so an import and a concurrent MCP call collapse to one presence entry. Presence is strictly best-effort: it never fails, blocks, or alters an import, it is capped at ~2 s before the apply and never awaited after it, and it never writes to the document. Human (browser-session) imports and the create route skip it entirely. The label matches what version history records as the author — the token's own name for append/replace, Repo Sync for mode=sync.
New users are dropped straight into a working, AI-assisted document instead of an empty document list, to avoid the "blank page" problem and reach value quickly.
/d/<id>?welcome=1) with the AI panel open. Engaged = the user owns a document other than their welcome doc that has content (≥1 persisted Yjs update). Once engaged, the user's onboarded_at is stamped and logins go to the normal document list (/docs).onboarded_at is stamped: eagerly, the moment the user creates a real (non-welcome) document — via POST /api/docs or the create_document MCP tool / in-app assistant — so the flag reflects engagement immediately rather than lagging. As a fallback, resolveOnboarding() also re-checks engagement and stamps lazily on the next login or /auth/me probe (covering docs that gained content through other paths). The welcome-doc seeding path deliberately never stamps.metadata.kind: 'welcome-kickoff') that is sent through the normal /api/chat pipeline but filtered out of the transcript. The ?welcome=1 flag is stripped after firing so refreshes don't re-greet.migrations/1783000000000_add-onboarding-to-users.js): users.welcome_doc_id (FK → documents, ON DELETE SET NULL) and users.onboarded_at (timestamptz; NULL until engaged).server/onboarding.js): seedWelcomeDoc() (idempotent; uses documents.createDocument + documentService.updateDocument with buildYjsNode to write content server-side — no live websocket needed), isEngaged(), resolveOnboarding(), and markEngagedFromDocCreation() (idempotent, best-effort stamp called from the real-doc creation paths). The welcome content lives in server/onboarding/welcome-template.js.server/auth/routes.js): the OAuth callback and dev-login resolve onboarding (seed: true) and choose the redirect; GET /auth/me resolves it read-only (seed: false) and returns welcomeDocId / onboarded so the client can route on refresh and dev-bypass.POST /api/docs (server/index.js) and the create_document MCP tool (server/mcp/tools/create-document.js) call onboarding.markEngagedFromDocCreation(userId) after creating the doc — fire-and-forget, so it never blocks or fails creation.App.jsx reads ?welcome=1 (and the /auth/me fields on the landing route), opens the panel, and calls aiChat.sendWelcomeMessage() once. The kickoff/greeting lives in client/src/contexts/AiChatContext.jsx; the hidden message is filtered in client/src/components/AiChatMessages.jsx.Separate from the in-app welcome doc above: a plain-text-style HTML welcome email sent to new sign-ups. It is never sent automatically — an admin sends it per user from a button on each row of the Admin page.
client/src/pages/AdminPage.jsx): each user row has a "✉ Welcome email" button; once sent it shows "✓ Welcome email" and displays the send date on hover. Re-sending prompts for confirmation. A confirm dialog notes that the admin is BCC'd.server/api/admin.js): POST /api/admin/users/:userId/welcome-email (behind requireAdmin) looks up the user, derives their first name from users.name, sends the email, and stamps users.welcome_email_sent_at on success. Returns 503 if SES is unconfigured, 502 on send failure (no stamp written in either case).server/email.js → sendWelcomeEmail): subject "Welcome to Squire Docs", body from the Squire-authored source doc, addressed "Hi <first name>," (falls back to "there"; first name is HTML-escaped). BCCs ADMIN_EMAIL and sets Reply-To to ADMIN_EMAIL so replies reach a real inbox rather than the no-reply From. Like share email, delivery to external recipients requires SES production access.migrations/1794000000000_add-welcome-email-sent-to-users.js): users.welcome_email_sent_at (timestamptz, NULL until first sent).This editor also supports external AI agents via the Model Context Protocol (MCP), enabling programmatic document manipulation through AI assistants like Claude Code, Kiro (AWS's spec-driven agentic IDE), and Claude Desktop.
isolated-vm V8 isolate (128 MB memory limit) with zero Node.js API access, on a dedicated worker threadsk_sqd_; legacy sqd_ tokens remain valid) as a simpler alternative to OAuth for programmatic access. Tokens work on the REST /api routes too, with scope enforcement: reads require documents:read, mutations require documents:write. MCP-connected agents can also self-mint temporary tokens (scoped at or below their own grant, auto-expiring, cascade-revoked with their minting credential) via the create_access_token tool — delivered as a one-shot claim (GET /api/tokens/claim, Bearer claim secret, 5-min TTL, Redis-backed) that writes the token bytes straight to disk so the credential never transits model context (inline: true is the explicit opt-in for shell-less agents). A token's name is the agent's public identity — the live presence label people see while it works in a document, and the version-history author — so both minting surfaces default to the agent's own name (the OAuth-registered client name, e.g. Claude Code, falling back to AI Agent) rather than to a description of the operation, and create_access_token takes an optional name when the caller wants to say what it is called (server/mcp/auth/token-naming.js). Existing token names are never rewrittenDocument Management:
create_document - Create new documents with a title (use modify to add content). Retyping bulk markdown through its markdown parameter is deflected (feature 019): at/above 2,048 UTF-8 bytes the success result appends a byte-channel nudge, and at/above 10,240 bytes the call is soft-refused with an instructive error unless allowRetyped: true is passed (always honored — the escape hatch for shell-less agents). Thresholds are tunable via CREATE_DOCUMENT_NUDGE_BYTES / CREATE_DOCUMENT_REFUSAL_BYTESimport_markdown_file - Sync/import an existing markdown file over the REST byte channel: accepts NO content, no path — returns one ready-to-run compound shell command (one-shot token claim + curl import + receipt write-back) that leaves the file a valid mode=sync baseline. Intents: create (default), update (mode=replace), sync (default when docGuid is given). Requires documents:write; mints via the same claim machinery as create_access_token. Its description states the guidance split (feature 054): this whole-file path is for authoring, importing, and bulk updates, while modify is preferred for a small targeted edit — especially to a document someone is actively editing, or to repair one damaged nodelist_documents - List accessible documents (or content-search them); rows include clock, lastModifiedAt, and the containing space (name + id) when there is one, and a space filter (all|personal|<spaceId>) scopes the list. updatedSince filters to recently edited docs for incremental syncs. Content searches accept updatedAfter (search path only, strictly after updatedAt, filtered before ranking) — distinct from the list-path-only updatedSinceshare_document - Share with users and set permissions; same semantics as the in-app Share dialog since 053 (any viewer can share at up to their own role, unknown emails become pending invites, and the invite email honors the sender's email_enabled gate)set_document_title - Update document titlesReading:
read_document - Read document with optional XPath filtering (structured JSON or Markdown output). Pass versionId (version UUID or clock number string) to read a historical version with identical xpath/format semantics — historical reads create no presence session and return version metadata. The old read_document_version tool remains executable as a hidden deprecation alias but is no longer advertised (feature 019 DR-1)get_collaborators - See who else is editingTool Documentation:
get_tool_documentation - Full API reference for the script tools (modify, compare_document_versions) and the REST byte channel (rest_api), whole or by section. Requires no scope (feature 019 DR-1): it serves static text, so a write-only token can read the docs it needs. MCP clients such as Claude Code truncate tool descriptions at 2KB, so the script tools ship short (≤2KB) descriptions that point here, and script errors append a hint directing agents here. The reference text lives in server/mcp/tools/tool-documentation/; the in-app chat agent gets it inline via each tool's chatDescription. The MCP initialize response's instructions field also tells agents to fetch these docs before writing scriptsSandboxed Script Execution:
modify - Execute TypeScript scripts with direct Yjs API access
sourceDocGuids): up to 10 other documents can be exposed read-only inside the script as a sources global (viewer access suffices; loaded as DB snapshots, no presence session). A cloneBlocks() helper deep-copies their content into the target with full formatting fidelity (marks, tables, nested lists) — Yjs nodes can't move between docs, so cloning re-creates them. This lets the agent concatenate/merge documents entirely inside the sandbox instead of re-typing content through chat. Mutation attempts on a source throw; sources never stream updates back, so rollback/undo/conflict semantics stay target-onlydocument_images row) is copied into the edited document and the src rewritten (imagesCopied in the result), since the image route authorizes strictly against the URL's docId. Images referencing documents the acting user can't read are stripped and reported in imageErrorschanged, operationCount, summary) plus the updated document content so the agent's view stays current without re-reading (omitted for very large documents)server/mcp/diagram-validate.js / diagram-worker.js): Mermaid via the same mermaid.parse() the editor uses, SVG via the same sanitize policy the editor renders with (shared/svg-sanitizer.mjs) plus an XML well-formedness check. Problems come back as mermaidErrors / svgErrors arrays ({ block, error, source }) plus a corrective message. This is non-blocking — the edit still applies — so the agent can fix the block in a follow-up instead of leaving the user with a broken renderHistory:
list_document_versions - List version history with optional nested subversions and time-based filteringset_document_version_name - Create, rename, or delete named versionsrestore_document_version - Restore to previous versioncompare_document_versions - Compare two versions using custom TypeScript scriptsundo - Undo this agent's most recent edit (log-derived: works across sessions/restarts; repeated calls step back through the agent's own edits; successful results carry a diff of what the revert changed, in modify's shape)redo - Reapply the agent's most-recently-undone edit (inverts the undo's recorded inverse; successful results carry a diff like undo's)Generate MCP OAuth secrets (required for agent authentication):
./script/generate-mcp-secrets.sh
Add the output to your .env file.
Configure your AI agent to connect to the MCP server:
{
"mcpServers": {
"collaborative-editor": {
"command": "node",
"args": ["path/to/collab/server/mcp/index.js"],
"env": {
"DATABASE_URL": "postgresql://user:pass@localhost:5432/collab_db"
}
}
}
}
Authenticate and start editing - The agent will guide you through OAuth authentication, then you can use natural language to edit documents.
Alternative: API Token Authentication
For programmatic or headless access, users can create personal API tokens from the Settings page instead of going through the OAuth flow. Tokens are prefixed with sk_sqd_ (tokens created before the prefix change use sqd_ and remain valid) and stored as SHA-256 hashes. Pass the token as a Bearer token in the Authorization header. An MCP-connected agent that has no token can mint a temporary one itself with the create_access_token tool — capped at the agent's own scopes (default documents:read), expiring after at most 24 hours, and revoked automatically when the minting credential is revoked.
API tokens also authenticate against the REST /api routes, so scripts can move document content over plain HTTP without an MCP client. For example, exporting a document as Markdown is one curl:
curl -H "Authorization: Bearer sk_sqd_..." \
"https://squiredocs.com/api/docs/<docId>/export?format=markdown" -o doc.md
Token scopes are enforced on REST routes: GET requests require documents:read, mutating requests require documents:write (new tokens get both by default). A request with an insufficient scope fails with 403 {"code": "INSUFFICIENT_SCOPE"}. Browser sessions and OAuth flows are unaffected. Agents connected over MCP can fetch this recipe with get_tool_documentation({ tool: "export_api" }).
// AI agents use TypeScript scripts for editing:
// 1. Create a new document with a title
const { docGuid } = await create_document({ title: "My Document" });
// 2. Execute script to add content (session is created automatically)
await modify({
docGuid,
script: `
export default function edit(doc) {
// Find and format all TODO items as bold
const blocks = doc.toArray();
blocks.forEach(block => {
if (block instanceof Y.XmlElement) {
const text = findTextNode(block);
if (text) {
const content = extractText(text);
const index = content.indexOf('TODO');
if (index >= 0) {
text.format(index, 4, { bold: true });
}
}
}
});
// Add a summary section at the end
const heading = new Y.XmlElement('heading');
heading.setAttribute('level', 2);
const headingText = new Y.XmlText();
headingText.insert(0, 'Action Items');
heading.insert(0, [headingText]);
doc.insert(doc.length, [heading]);
}
function findTextNode(element) {
for (const child of element.toArray()) {
if (child instanceof Y.XmlText) return child;
if (child instanceof Y.XmlElement) {
const found = findTextNode(child);
if (found) return found;
}
}
return null;
}
function extractText(xmlText) {
const delta = xmlText.toDelta();
return delta.map(op =>
typeof op.insert === 'string' ? op.insert : ''
).join('');
}
`
});
// Natural language examples with modify:
"Find all TODO items and make them bold"
"Add a summary section at the end with bullet points"
"Create a table of contents based on headings"
"Find all links and convert them to a reference list"
"Reorganize the document with all headings first"
// IMPORTANT: Creating mixed formatting
// When inserting text with different formatting, ALWAYS pass {} for unformatted text:
await modify({
docGuid: "abc-123",
script: `
export default function edit(doc) {
const para = new Y.XmlElement('paragraph');
const text = new Y.XmlText();
// ✅ CORRECT - pass {} to prevent inheriting formatting
text.insert(0, 'BOLD', { bold: true });
text.insert(4, ' normal', {}); // Empty object prevents inheriting bold!
// ❌ WRONG - omitting attributes inherits formatting from previous position
// text.insert(0, 'BOLD', { bold: true });
// text.insert(4, ' normal'); // This would be bold too!
para.insert(0, [text]);
doc.insert(doc.length, [para]);
}
`
});
## Hierarchical Document Editing
This editor supports advanced hierarchical document structures with nested lists, subsections, and complex content organization using the `modify` tool with direct Yjs API access.
### Creating Nested Content
**Build complex hierarchical structures with TypeScript:**
```typescript
await modify({
docGuid: "abc-123",
script: `
export default function edit(doc) {
// Create a nested bullet list
const list = new Y.XmlElement('bulletList');
// Main item
const item1 = new Y.XmlElement('listItem');
const p1 = new Y.XmlElement('paragraph');
const t1 = new Y.XmlText();
t1.insert(0, 'Main item');
p1.insert(0, [t1]);
item1.insert(0, [p1]);
// Nested sub-list
const subList = new Y.XmlElement('bulletList');
const subItem = new Y.XmlElement('listItem');
const subP = new Y.XmlElement('paragraph');
const subT = new Y.XmlText();
subT.insert(0, 'Sub item');
subP.insert(0, [subT]);
subItem.insert(0, [subP]);
subList.insert(0, [subItem]);
// Add sub-list to main item
item1.insert(1, [subList]);
list.insert(0, [item1]);
doc.insert(0, [list]);
}
`
});
await modify({
docGuid: "abc-123",
script: `
export default function edit(doc) {
// Helper to create heading
function createHeading(level, text) {
const h = new Y.XmlElement('heading');
h.setAttribute('level', level);
const t = new Y.XmlText();
t.insert(0, text);
h.insert(0, [t]);
return h;
}
// Helper to create bullet list item
function createListItem(text) {
const item = new Y.XmlElement('listItem');
const p = new Y.XmlElement('paragraph');
const t = new Y.XmlText();
t.insert(0, text);
p.insert(0, [t]);
item.insert(0, [p]);
return item;
}
// 1. Create main heading
doc.insert(0, [createHeading(1, 'Project Plan')]);
// 2. Add sections with nested content
doc.insert(1, [createHeading(2, 'Objectives')]);
const list = new Y.XmlElement('bulletList');
const obj1 = createListItem('Increase user engagement');
const obj2 = createListItem('Improve performance metrics');
// Add nested items under first objective
const nestedList = new Y.XmlElement('bulletList');
nestedList.insert(0, [
createListItem('Implement new UI components'),
createListItem('Add interactive features')
]);
obj1.insert(1, [nestedList]);
list.insert(0, [obj1, obj2]);
doc.insert(2, [list]);
}
`
});
This approach enables AI agents to create professional documents with proper hierarchical structure, including nested lists, subsections, and complex content organization - all in a single atomic operation.
You can programmatically update documents using Node.js scripts. This is useful for automation, migrations, or bulk edits.
The TipTap editor uses the 'default' field with Y.XmlFragment for ProseMirror content. Do not use Y.Text or other field names - they won't appear in the editor.
A sample script is provided at script/edit-default-doc.js. Here's how it works:
const Y = require('yjs');
const { WebsocketProvider } = require('y-websocket');
const WS_URL = process.env.WS_URL || 'ws://localhost:3001/s';
const DOC_NAME = process.env.DOC_NAME || 'default-doc';
const doc = new Y.Doc();
// TipTap uses 'default' field with XmlFragment for ProseMirror content
const xmlFragment = doc.get('default', Y.XmlFragment);
const provider = new WebsocketProvider(WS_URL, DOC_NAME, doc, {
connect: true
});
provider.on('sync', (isSynced) => {
if (isSynced) {
// Create a paragraph node with text
const paragraph = new Y.XmlElement('paragraph');
const textNode = new Y.XmlText();
textNode.insert(0, 'Your text here');
paragraph.insert(0, [textNode]);
// Insert at the end of the document
xmlFragment.insert(xmlFragment.length, [paragraph]);
// Clean up
setTimeout(() => {
provider.destroy();
process.exit(0);
}, 1000);
}
});
# Edit the default document
node script/edit-default-doc.js
# Edit a specific document
DOC_NAME=my-document node script/edit-default-doc.js
# Connect to a different server
WS_URL=ws://example.com:3001 node script/edit-default-doc.js
sync event before making editsY.XmlFragment in the 'default' fieldY.XmlElement('paragraph') nodes containing Y.XmlText nodesprovider.destroy() when done to close the connectionTo modify existing content, you can traverse the XmlFragment:
// Get all paragraphs
const paragraphs = Array.from(xmlFragment.toArray());
paragraphs.forEach((node, index) => {
if (node.nodeName === 'paragraph') {
// Modify paragraph content
const textNode = node.firstChild;
if (textNode && textNode.nodeType === 3) { // Text node
textNode.insert(textNode.length, ' appended text');
}
}
});
.
├── server/
│ ├── index.js # Express server with WebSocket
│ ├── postgres-persistence.js # PostgreSQL persistence adapter
│ ├── permissions.js # Centralized permission checks
│ ├── documents.js # Document and share management
│ ├── search.js # Hybrid full-text + semantic document search (RRF)
│ ├── search-indexer.js # Maintains the FTS + embedding search index
│ ├── redis.js # Redis caching layer
│ ├── auth/ # Human authentication (Google OAuth, JWT)
│ ├── api/
│ │ ├── chat.js # In-app AI chat endpoint (AI SDK + configurable model)
│ │ ├── ai-providers.js # Provider registry (Anthropic, Google, OpenAI, z.ai, OpenRouter): key validation, SDK factories, web search, capabilities
│ │ ├── chat-models.js # Model registry (Claude, Gemini, GPT, GLM; direct + via OpenRouter) with lazy provider loading
│ │ ├── chat-tools.js # Wraps MCP tools as AI SDK tool definitions
│ │ ├── byok-settings.js # Bring-your-own-key (BYOK) API key management
│ │ ├── web-fetch.js # Web fetch tool used by the AI assistant
│ │ ├── app-settings.js # Global admin-editable settings (shared assistant default model)
│ │ └── admin.js # Admin dashboard endpoints
│ ├── ai-usage.js # AI usage metering (quota checks, cost computation, usage logging)
│ ├── email.js # Email: admin notifications (signup, login, credit limit, support), share invites, welcome email
│ ├── exception-notifier.js # Rate-limited exception email alerts
│ ├── chat-store.js # Chat persistence (CRUD with ownership checks)
│ └── mcp/ # Model Context Protocol integration
│ ├── index.js # MCP server entry point
│ ├── tools/ # MCP tools for document operations
│ ├── sandbox/ # isolated-vm script executor (worker thread + V8 isolate)
│ ├── yjs/ # Yjs utilities and serialization
│ ├── auth/ # MCP OAuth 2.0, PKCE flow, and API tokens
│ └── agent-presence.js # Agent session management
├── client/
│ ├── src/
│ │ ├── components/ # React components
│ │ │ ├── Editor.jsx # TipTap editor component
│ │ │ ├── EditorView.jsx # Editor page with header/toolbar
│ │ │ ├── DocList.jsx # Document list page
│ │ │ ├── ShareDialog.jsx # Share/permissions dialog
│ │ │ ├── AiPanel.jsx # AI assistant panel (dock/pop-out/mobile)
│ │ │ ├── AiChatMessages.jsx # Chat message list with markdown + tool cards
│ │ │ └── AiChatInput.jsx # Chat input textarea
│ │ ├── hooks/ # Custom React hooks
│ │ │ ├── useYjs.js # Yjs document and WebSocket management
│ │ │ └── useAiPanel.js # AI panel state (position, size, preferences)
│ │ ├── contexts/ # React contexts
│ │ │ ├── AuthContext.jsx # Authentication state
│ │ │ ├── AiChatContext.jsx # AI chat provider (AI SDK transport)
│ │ │ └── ByokContext.jsx # BYOK settings state
│ │ ├── pages/
│ │ │ └── SettingsPage.jsx # Settings: agents, API tokens, BYOK keys
│ │ └── main.jsx # Entry point
│ └── package.json
├── shared/ # Client/server-safe modules (no Node built-ins)
│ ├── prosemirror-schema.js # The single ProseMirror schema (marks + nodes)
│ ├── format-registry.js # Single source of truth for inline-mark syntax
│ │ # (delimiters, HTML tags, style props) + derived
│ │ # tolerant-parser metadata
│ ├── svg-sanitizer.mjs # Strict SVG sanitize policy (shared by editor + server)
│ └── markdown/ # Markdown → ProseMirror parser
│ ├── index.js # markdownToPm(md, diffMark, { strict }) dispatch
│ ├── strict-parser.js # Frozen exact-dialect parser (diff engine)
│ └── tolerant/ # Tolerant CommonMark + GFM subset parser
├── migrations/ # Database migrations
├── script/ # Utility scripts
│ ├── generate-mcp-secrets.sh # Generate MCP OAuth secrets
│ ├── edit-default-doc.js # Example programmatic editing
│ ├── jest-llm-reporter.js # Compact Jest reporter for LLM contexts
│ └── vitest-llm-reporter.mjs # Compact Vitest reporter for LLM contexts
├── docs/ # Documentation
│ ├── mcp-quickstart.md # MCP setup guide
│ ├── mcp-integration-summary.md # Complete MCP documentation
│ ├── permissions.md # Permission system details
│ └── dev.md # Development environment guide
└── package.json
yjs_updates (the append-only update log — the sole source of truth; each row carries its clock, attribution, write-time meaningful classification, and a via_sync channel marker). via_sync records how content reached the server, never who wrote it: it is true only for rows that arrived on a sync catch-up frame (a reconnecting client re-supplying state the server lacked), and null — meaning "not known to be sync" — for every live edit and every row written before the marker existed. The stored row keeps the relaying connection's identity (that is the honest record of how the bytes arrived), but no author-displaying surface trusts it: for a via_sync row the displayed author comes from resolving the update's Yjs origin clients against earlier directly-attributed rows in the same document, so a genuine offline edit replayed on reconnect is credited to its author whenever that session already has directly-attributed rows in the document; with no such evidence the content is displayed as Synced content rather than credited to the relayer or to a guess. via_sync never affects replay or permissions. It does affect two things: displayed authorship, and undo — log-derived undo deliberately excludes sync-relayed rows from an identity's own edit runs, so a reconnect cannot make someone's undo reach content they never wrote. Server-side writes (imports, document seeds, title changes, the assistant's inserts, restores) author under a client identity borrowed for the length of one transaction, rather than under the server document's own, so every Yjs client identity in the log maps to exactly one author by construction and every instance resolves it the same way. A borrowed identity is reused for the same author on the same document while it stays cached, and is re-minted whenever that would be unsafe; it is never shared between authors. Those writes also wait for the document's persisted state to finish loading before they touch it, so a write into a document nobody has open cannot race the load and losedocuments table (document info, creator)document_shares table (user-document access with roles)users table (OAuth user accounts, per-user AI credit allowance; signup and last-login IP + user-agent snapshots for abuse detection — admin-only)auth_events table (append-only signup/login trail with client IP + user-agent, 180-day retention via a daily purge job; powers multi-account abuse correlation)A Kubernetes CronJob performs nightly PostgreSQL backups to a dedicated, hardened S3 bucket:
s3://squiredocs-db-backups/ — SSE-KMS, versioning, Object Lock
(governance, 30-day retention), lifecycle to Glacier IR, block-all-publiccollab-postgres-<hostname>-<arch>-<timestamp>.sql.gz (timestamped
so a corrupt dump can never overwrite a good one)apk add), reads
the DB password from a mounted file (never argv/traced), uploads with a scoped
squiredocs-db-backup-writer credential, and a CloudWatch dead-man alarm emails
if no backup lands in 26hThe uploader uses AWS CLI v2 because s3cmd is incompatible with Object Lock. The hosted service's bucket, alarm, and rehearsed restore procedure are documented in its private operations repository.
This project uses node-pg-migrate for database schema management.
The standard way to run migrations is using node-pg-migrate's CLI:
# Run all pending migrations (up)
npm run migrate
# or: npx node-pg-migrate up
# Rollback last migration (down)
npm run migrate:down
# or: npx node-pg-migrate down
# Create a new migration file
npm run migrate:create migration_name
# or: npx node-pg-migrate create migration_name
# Redo last migration (down then up)
npm run migrate:redo
# or: npx node-pg-migrate redo
Note: node-pg-migrate requires the database to already exist. It only manages schema within the database, not database creation itself.
Migration files are located in the migrations/ directory. Each migration file exports up and down functions:
up: Applies the migrationdown: Rolls back the migrationExample migration structure:
exports.up = (pgm) => {
pgm.createTable('my_table', {
id: 'id',
name: { type: 'varchar(100)', notNull: true },
});
};
exports.down = (pgm) => {
pgm.dropTable('my_table');
};
VITE_WS_URL matches your server URLChange the port using the PORT environment variable:
PORT=3002 npm start
If you encounter database connection errors:
Check PostgreSQL is running:
# macOS (Homebrew)
brew services list
# Linux
sudo systemctl status postgresql
Verify database exists:
psql -l | grep collab_db
Check connection credentials:
psql -h localhost -U postgres -d collab_db
Reinitialize schema if needed:
npm run init-db
Or run migrations directly:
npm run migrate
server/permissions.js)client/index.html (SPA entry) and each static marketing page (client/public/landing.html, pricing.html, about.html). If you update tracking IDs, change all of them.client/public/ holds the public marketing pages — landing.html (/), pricing.html (/pricing), about.html (/about) — sharing client/public/marketing.css. They're routed by the staticPagesPlugin in client/vite.config.js (dev) and explicit Express routes in server/index.js (prod); all other unknown paths fall through to the React SPA. Privacy/Terms remain React pages (client/src/pages/).CRITICAL: Every server-side query that reads or mutates a user-owned resource must include the authenticated user's ID in its WHERE clause. Never look up a record by its primary key alone — always scope to the owning user.
// ❌ WRONG - Allows any authenticated user to access any record by ID
const result = await pool.query('SELECT * FROM chats WHERE id = $1', [chatId]);
// ✅ CORRECT - Enforces ownership at the database level
const result = await pool.query('SELECT * FROM chats WHERE id = $1 AND user_id = $2', [chatId, userId]);
Why this matters:
Where to check:
server/chat-store.js — all chat CRUD operations require userIdserver/permissions.js — document access uses the document_shares tableserver/api/admin.js — admin endpoints use requireAdmin middleware with DB verificationWhen adding new endpoints or data stores, ensure:
AND user_id = $N (or equivalent ownership join)req.user.userId to the data layerFor HTML email templates, always escape user-controlled values with escapeHtml() before interpolation (see server/email.js).
CRITICAL: When working with Y.XmlText nodes that may contain formatting (bold, italic, etc.), always use toDelta() to extract plain text, never toString().
// ❌ WRONG - toString() returns XML markup when text has formatting
const text = xmlTextNode.toString(); // Returns "<bold>Hello</bold> world"
// ✅ CORRECT - toDelta() returns plain text regardless of formatting
const delta = xmlTextNode.toDelta();
const text = delta.map(op => typeof op.insert === 'string' ? op.insert : '').join(''); // Returns "Hello world"
Why this matters:
toString() returns XML serialization like <bold>text</bold> when formatting is presentfind + format operations after text has been formattedWhere to check:
cursor-operations.jstext-operations.jsSee server/mcp/yjs/cursor-operations.js (getAllText function) and server/mcp/yjs/text-operations.js (extractText function) for correct examples.
Comprehensive test suite with Jest (backend) and Vitest (frontend).
Running tests in the dev pod (recommended — matches the CI environment; see docs/dev.md for full setup):
# Backend tests
kubectl exec deployment/app-dev -n collab -- sh -c "cd /local-dev && npm test"
# Frontend tests
kubectl exec deployment/app-dev -n collab -- sh -c "cd /local-dev/client && npm test"
# All tests
kubectl exec deployment/app-dev -n collab -- sh -c "cd /local-dev && npm run test:all"
Running tests locally (if you have Node 22+ and PostgreSQL):
npm test # Backend tests
npm run test:client # Frontend tests
npm run test:all # All tests
npm run test:watch # Watch mode
npm run test:coverage # Coverage
cd client && npm run test:coverage
cd client && npm run lint:colors # Dark-mode token gate: fails on hardcoded color literals in themed CSS
Test Structure:
server/__tests__/ - Server and WebSocket testsclient/src/**/__tests__/ - Component and hook tests__tests__/integration/ - End-to-end collaboration testsclient/src/test/utils.jsx - Shared test utilities and mocksLLM-friendly test reporters: Custom reporters (script/jest-llm-reporter.js for Jest, script/vitest-llm-reporter.mjs for Vitest) compress passing-suite output to a single summary line, expanding details only on failure. This reduces output from thousands of lines to a compact summary suitable for LLM context windows.
For more detailed information, see:
MIT
db-migrateGOOGLE_CLIENT_ID / GOOGLE_CLIENT_SECRET: Google OAuth credentials for user sign-inGOOGLE_REDIRECT_URI: OAuth callback URL for Google sign-in (default: ${APP_URL}/auth/google/callback)ACCESS_TOKEN_SECRET, REFRESH_TOKEN_SECRET: Secrets for signing user session JWTs. When unset, the entrypoint generates them into ${SQUIRE_DATA_DIR}/secrets.json (mode 0600); the environment always wins over the file. JWT_SECRET is read by no server code.API_KEY_ENCRYPTION_KEY: The "legacy" AES-256-GCM key used to encrypt stored BYOK API keys at rest (64-char hex = 32 bytes). Decrypts all untagged (pre-rotation) values and is the default encryption key. Required in production; generated into the secrets file when unset (not when the API_KEY_ENCRYPTION_KEYS keyring is in use). Losing a generated key (for example by deleting the data volume) makes every stored BYOK key undecryptable.API_KEY_ENCRYPTION_KEYS / API_KEY_ENCRYPTION_PRIMARY: Optional keyring for rotating the BYOK encryption key without invalidating stored keys. API_KEY_ENCRYPTION_KEYS is a comma-separated list of id:hex entries (e.g. 2:<64hex>); API_KEY_ENCRYPTION_PRIMARY names the id used to encrypt new values (defaults to legacy, which keeps the byte-identical untagged format). Stored values encrypted under a non-legacy key are tagged k<id>:…. Because the app runs multiple replicas under a rolling deploy, rotation is a two-phase process (a single-deploy flip would leave old pods unable to decrypt new pods' writes): (1) distribute — add the new key to API_KEY_ENCRYPTION_KEYS on all pods with API_KEY_ENCRYPTION_PRIMARY still legacy, deploy, and wait for the rollout to complete so every pod can decrypt it; (2) promote — set API_KEY_ENCRYPTION_PRIMARY to the new id and deploy. Then run node script/reencrypt-byok-keys.js to migrate stored values, --verify to gate (it decrypts every value and exits non-zero if any is stale or undecryptable), and only once verify is clean remove the old key. Run the script and --verify with the serving pods' environment (e.g. kubectl exec into a pod), not a local .env with hand-typed keys. See server/crypto.js and script/reencrypt-byok-keys.js.CLIENT_URL: Base URL of the frontend, used for post-sign-in redirects, CORS, and absolute links in emails (default: APP_URL)REDIS_HOST / REDIS_PORT: Redis connection (defaults: localhost / 6379)AGENT_CLAIM_TTL_MS: Lifetime of the cluster-wide agent presence claim in ms (default: 15000). Bounds the worst-case avatar gap after a claim-holding instance crashes.AGENT_CLAIM_HEARTBEAT_MS: Presence-claim heartbeat interval in ms (default: 5000, TTL/3). Holders refresh their claim on this cadence; awareness-silent sessions probe for a freed claim.AGENT_CLAIM_OP_TIMEOUT_MS: Per-operation bound on presence-claim Redis commands in ms (default: 500). On timeout or error the session fails open and announces anyway — claim coordination never delays or fails agent tool calls.AI_CHAT_MODEL: Optional fallback default for the in-app AI assistant's model on the shared key. The shared default is now editable at runtime from the Admin page (stored in app_settings, see below); AI_CHAT_MODEL only applies when no admin selection has been made, and the DEFAULT_MODEL_KEY code constant in server/api/chat-models.js (claude-sonnet-5-5) applies when neither is set. An admin-pinned per-user override (users.chat_model_override) takes precedence over both for that one user. Valid values are any model key whose provider has a shared server key configured: the claude-* keys (with ANTHROPIC_API_KEY), the gemini-* keys (with GOOGLE_GENERATIVE_AI_API_KEY), and the or-* OpenRouter gateway keys (with OPENROUTER_API_KEY). A value whose provider has no server key — including all OpenAI and direct z.ai models, which are BYOK-only — is ignored with a logged warning and resolution falls through to the built-in default; the same degradation applies to a stored admin selection whose provider key was later removed, so deleting a server key is always a safe rollback. The built-in default is DEFAULT_MODEL_KEY when ANTHROPIC_API_KEY is set, otherwise or-kimi-k3 when OPENROUTER_API_KEY is set, otherwise gemini-3.5-flash when GOOGLE_GENERATIVE_AI_API_KEY is set (SHARED_FALLBACK_KEYS in server/api/chat-models.js), so an instance with any one assistant key gets a working assistant. With none of the three set, the shared assistant is off: each person needs their own BYOK key (see "Assistant not configured" below).ANTHROPIC_API_KEY: Anthropic API key (required when using claude-haiku, claude-sonnet, or claude-opus model)GOOGLE_GENERATIVE_AI_API_KEY: Google AI API key (required when using a gemini-* model)OPENROUTER_API_KEY: Shared OpenRouter server key (optional). When set, the OpenRouter gateway models (or-* — Kimi, Qwen, MiniMax, GLM) become selectable as the shared assistant's default in the Admin page and are metered like any shared usage; when unset they remain BYOK-only and simply don't appear in the admin pickerADMIN_EMAIL: Email address for admin notifications — sign-up, login, AI credit-limit exhaustion, support requests, and unhandled exception alerts (optional; all notifications skipped if unset)SMTP_HOST, SMTP_PORT (default 465), SMTP_SECURE (default true on port 465, else STARTTLS), SMTP_USER, SMTP_PASS, SMTP_FROM: Generic SMTP settings (feature 058). Email is off, with one boot log line, when SMTP_FROM is unset. The SES_* names below remain as aliases; the generic name wins when both are set, and the SES default host applies only when configured through the SES_* names.SES_FROM_EMAIL: Alias for SMTP_FROM. AWS SES verified sender address for transactional email — admin notifications, document share invitations/notifications, plus the admin-triggered beta welcome email (optional; email is skipped if unset). Set to [email protected]. Sending invitations to external (non-admin) recipients requires the SES account to have production access (out of the sandbox) and the squiredocs.com domain verified in SES. Note: user-initiated share email is additionally gated per user by the users.email_enabled flag (off by default; toggled from the Admin page) — admin/ops notifications to ADMIN_EMAIL are not gatedSES_SMTP_HOST: SES SMTP endpoint (default: email-smtp.us-west-2.amazonaws.com)SES_SMTP_USER: SES SMTP usernameSES_SMTP_PASS: SES SMTP passwordSTORAGE_DRIVER: local or s3 image storage (default: s3 when S3_IMAGE_BUCKET is set, else local). The local driver stores bytes under ${SQUIRE_DATA_DIR}/images and serves them through GET /api/docs/:docId/images/:imageId/raw and GET /api/chat/attachments/raw, which run the same access checks as the resolve routes and also accept the accessToken session cookie (a browser <img> sends only cookies). Bytes are not migrated when the driver changes. More than one replica needs a shared data volume or env secrets plus STORAGE_DRIVER=s3.S3_IMAGE_BUCKET / S3_IMAGE_REGION: Dedicated S3 bucket + region for document image storage with the s3 driver. Use a separate bucket from DB backups.S3_ENDPOINT: S3-compatible endpoint (for example MinIO), using path-style addressing; also added to the CSP image sourcesGUARDRAIL_FRESHNESS_SECONDS: Collaboration guardrail freshness window in seconds (default: 10) — a human-attributed update deleting agent content created within this window raises the collab-guardrail alert (feature 021)GUARDRAIL_SUPPRESSION_MS: Guardrail alert suppression window per (doc, user) pair in ms (default: 300000); suppressed matches are counted and carried on the next alertCOLLAB_READ_GAP_RETRIES: Max full re-fetches when getYDoc detects a clock gap in the update log (default: 2; feature 021)COLLAB_READ_GAP_RETRY_DELAYS_MS: Comma-separated waits between gap-read retries in ms (default: 100,300)COLLAB_RECONCILE_INTERVAL_MS: How often each pod checks its bound documents against the update log and applies anything it is missing (default: 30000; feature 057). A pod learns about other pods' edits through Redis fan-out, which is best-effort — this check is what makes a dropped message a delay rather than permanent divergence, and it runs whether or not Redis is available. Steady-state cost is one batched query per pod per interval regardless of how many documents are open; only documents the log has actually moved past fetch any rows. An invalid value falls back to the defaultRL_IMPORT_PER_MIN / RL_EXPORT_PER_MIN: Per-user rate limits for the markdown byte channel — POST /api/docs/import and PUT /api/docs/:docId/import share the import budget (a dryRun=true preview is charged like a push), GET /api/docs/:docId/export has its own (default: 240 each). Sized for bulk file sync: a directory of specs pushed or pulled in one pass stays well under it. Over budget returns 429 with Retry-After plus retryAfterSeconds and guidance in the body; nothing is applied.RL_AUTH_PER_MIN / RL_TOKEN_PER_MIN: Per-IP rate limits for /auth/* and POST /mcp/auth/token (default: 30 each)RL_SEARCH_PER_MIN: Per-user rate limit for content search, GET /api/docs?searchMode=content (default: 30)RL_CHAT_PER_MIN: Per-user rate limit for POST /api/chat (default: 30)RL_UPLOAD_PER_MIN: Per-user rate limit for chat attachment uploads, POST /api/chat/attachments (default: 20)RL_VERSION_HISTORY_PER_MIN: Per-user rate limit shared by the version-history endpoints — history, diff, versions, restore (default: 60)RL_FORCE_MEMORY: Set to 1 to use the per-process limiter instead of Redis (dev and tests). All RL_* budgets are fixed one-minute windows.RL_COLLAB_SKIP_PER_MIN: Per-user rate limit for the render-skip beacon POST /api/collab/render-skip-report (default: 30)RL_MCP_PER_MIN: Per-user rate limit for MCP tool calls — both POST /mcp with method tools/call and POST /mcp/tools/call, which share one budget (default: 120). Keyed on the token's owning user, so additional tokens buy no additional budget. The handshake (initialize, tools/list, ping) and agent registration/auth are not charged. Over budget returns 429 with Retry-After plus guidance telling the agent to batch edits rather than retry immediatelyCREATE_DOCUMENT_NUDGE_BYTES / CREATE_DOCUMENT_REFUSAL_BYTES: Teaching thresholds for retyped markdown in the create_document MCP tool, in UTF-8 bytes (defaults: 2048 / 10240). At/above the nudge the success result appends a byte-channel pointer; at/above the refusal the call is soft-refused unless allowRetyped: true. Read per call (no restart needed); any invalid pair (non-integer, ≤ 0, refusal ≤ nudge) falls back to BOTH defaultsAWS_ACCESS_KEY_ID / AWS_SECRET_ACCESS_KEY: IAM credentials for the document image bucket (s3:PutObject/GetObject/DeleteObject)sk_sqd_--stdoutdocker compose exec app squire <command>design/self-hosting-local-mode.md ("Instance modes", "Sign-in links", "The squire CLI").--versionupunauthorizeddeniedmanifest unknown.envSQUIRE_VERSIONCOMPOSE_PROJECT_NAME=squire-docs-<hex>{ main "$@"; }curl | sh/install.sh and /self-host.md (the repository's AGENTS.md) byte for byte. A self-hosted instance serves a documentation variant that names the instance's own origin, carries no analytics tag, and links only to pages the instance serves; the hosted pages are pinned by golden hashes. The self-hosting guide is documentation/self-hosting.md (/documentation/self-hosting).LICENSE (MIT), CONTRIBUTING.md, SECURITY.md ([email protected]).design/self-hosting-local-mode.md ("Release files and the install script", D12, D13).served with clock gapCOLLAB_RECONCILE_INTERVAL_MS (and on subscriber reconnect, after each
document's subscription is established, and whenever a read is served from a
copy known to be behind) and applies whatever it is missing. Repairs only
ever apply durable updates — nothing is rebuilt, re-saved or re-broadcast.
A bind whose load comes back incomplete is refused rather than kept, and
read_document labels its clock with what the served content provably
integrated, adding newestClock / stale / stalenessNote when the stored
document is ahead.converged rides every mode=sync receipt — applied, both no-op shapes, and dry run — and answers the question noop was being mistaken for: is the document now byte-identical to the pushed file? It measures the engine's own fidelity, so an apply-correct push over a concurrently edited document reads converged: true alongside docChangedSinceBaseline: true; those are deliberately separate signals. noop: true alone therefore no longer certifies success — noop: true, converged: false is the re-pull-and-repair signal, and the engine never retries, falls back, or rolls back on its own. operations and blocksChanged are both derived from what the apply layer actually did rather than from what was planned, and operations.skipped counts work apply declined: it is 0 on every healthy push, a dead-man switch rather than a routine counter.blocksChanged names every block the push changed — { blockIndex, blockType, excerpt, op }, where op is text (in-place character splice), reconcile (block merged against concurrent live edits), or structural (block inserted, deleted, or replaced as a unit). A boundary insertion has no baseline index of its own, so it is anchored on the preceding block plus position: "after" (or "start" at the document head). A push is therefore verifiable from the receipt alone, with no re-export-and-grep round trip. The report is derived from structuralOps, the same function that applies the plan, so it cannot drift from what actually landed. operations is retained alongside it: that counts hunks, this counts blocks, and the two need not agree. A block can legitimately read op: "reconcile" here and pushSide: "text" in overlaps — the two lists answer different questions, and the overlap contract's vocabulary is unchanged.baselineClock, currentClock, clockGap, and docChangedSinceBaseline report how far the document moved since the pusher's baseline — on applied, no-op, and dry-run receipts alike. currentClock is read at baseline-validation time, so it is ≤ the receipt's own clock; the two answer different questions and both are reported. The signal is clock-based, not content-based: a document edited and reverted to identical bytes still reports docChangedSinceBaseline: true, which is the honest answer to "has anyone touched this since you looked". docChangedSinceBaseline tests inequality rather than >, so a baseline ahead of the document does not read as "nothing changed" either.?strict=true (feature 054, default off): refuse to merge over changes the pusher has not seen. When the document moved since the baseline the push is rejected with 409 sync_baseline_stale carrying the gap and a remedy, and nothing is applied — no content change, no clock advance, no version entry, no presence settle. The gate sits between baseline validation and the sync engine, so "nothing was applied" is a property of control flow rather than a promise. Without strict, apply/merge/overlap behavior is byte-for-byte what it has always been and the staleness fields are purely advisory.?dryRun=true (feature 054, default off): compute the whole plan and apply nothing. Returns 200 with the same receipt shape plus dryRun: true and without markdown — two independent guards against a preview being mistaken for a fresh baseline, since a client that blindly writes receipt.markdown back over its file gets undefined rather than a stale baseline. No stored update, no version entry, no clock advance, no live fan-out, no search-index mark, and no agent presence announcement (no avatar, no selection). One disclosed exception: the staged image pass still runs, so a dry run may rehost an external image or copy a cross-document one, reported in images exactly as a real push would — that pass produces the canonical string the plan is diffed against, and skipping it would make the preview predict a different plan than the real call. strict is evaluated identically under dryRun, so dryRun=true&strict=true against a stale baseline returns the same 409 the real push would.strict and dryRun accept true/false/1/0 and 400 on anything else — never a quiet falsy default, since strict=yes silently reading as false would apply the merge the caller asked to refuse. Both are sync-only: on mode=append, mode=replace, or POST /api/docs/import they are a 400 naming the supported combination rather than being ignored.start survives the full round trip. Both serializer copies seed numbering from it and both parser modes preserve it, so a document whose list starts at 11 exports 11., 12., 13. and pushes back unchanged as a no-op instead of diffing every item. The strict parser is otherwise frozen; this is its one sanctioned exception, recorded in its header. The diff cache version was bumped (v10 → v11) so cached diffs computed against the old flattened numbering are never served.Repo Sync (<user>)). Optional on-behalf-of provenance (git author name/email, commit sha, url) may be supplied via X-Squire-On-Behalf-Of-{Name,Email,Commit,Url} headers (each ≤256 chars) and is surfaced in version history strictly as inert plain text.sync_baseline_missing/sync_baseline_invalid 400, sync_baseline_unavailable 410, sync_doc_mismatch 409, plus sync_baseline_stale 409 under strict only) with re-pull guidance and zero document mutation. Precedence is structural: every pre-existing rejection returns before the strict gate is reached, and baseline validation has no knowledge of strict. The server never degrades to whole-document replacement — mode=replace remains the explicit opt-in for clobber writes.| Change a member's role (owner) |
DELETE /api/spaces/:id/members/:userId | Remove a member; removing yourself is leaving (last owner cannot leave) |
DELETE /api/spaces/:id/invites | Revoke a pending invite by email (owner) |
PUT /api/docs/:docId/space | Move a document into a space or back to personal ({ spaceId } or { spaceId: null }) |
gemini-3-flashGOOGLE_GENERATIVE_AI_API_KEYgemini-3.1-pro — Gemini 3.1 Pro (Preview) (requires GOOGLE_GENERATIVE_AI_API_KEY)gemini-3.5-flash, gemini-3.5-flash-lite, gemini-3.6-flash, gemini-3.8-flash — Gemini 3.5 Flash, 3.5 Flash-Lite, 3.6 Flash and 3.8 Flash (requires GOOGLE_GENERATIVE_AI_API_KEY)gpt-6-astra, gpt-6.1-sol, gpt-6-luna, gpt-5.6-sol, gpt-5.6-terra, gpt-5.6-luna, gpt-5.5, gpt-5.4, gpt-5.4-mini — OpenAI models, selectable only when the user supplies their own OpenAI key (BYOK)glm-4.6, glm-4.7, glm-5, glm-5.2, glm-5.3, glm-5.3-flash — z.ai (Zhipu) GLM models, selectable only when the user supplies their own z.ai key (BYOK). z.ai speaks the OpenAI chat-completions wire format, reached via @ai-sdk/openai-compatible pointed at https://api.z.ai/api/paas/v4 (that provider targets chat-completions by default — z.ai has no Responses API — and parses GLM's reasoning_content into UI thinking blocks)or-glm-4.6, or-glm-4.7, or-glm-5, or-glm-5.2, or-glm-5.3, or-glm-5.3-flash — the same GLM models reached through the OpenRouter gateway (namespaced model ids like z-ai/glm-5.2), plus the curated gateway entries or-kimi-k3 (Kimi K3), the Kimi K2 series or-kimi-k2.7-code / or-kimi-k2.6 / or-kimi-k2.5 / or-kimi-k2-thinking, or-qwen3.8-max / or-qwen3.8-flash / or-qwen3.7-max / or-qwen3.7-plus (Qwen), or-minimax-m3 (MiniMax M3), or-deepseek-v4-pro / or-deepseek-v4-flash / or-deepseek-v4.1-flash (DeepSeek), or-mimo-v2.5-pro / or-mimo-v2.6-pro (Xiaomi MiMo), or-hy3 (Tencent Hy3), or-grok-4.7 (xAI Grok) and or-llama-3.1-8b (the deliberately minimal tier). server/api/chat-models.js is the authoritative list, with each entry's pricing and the date it was verified. All run over @ai-sdk/openai-compatible (base URL https://openrouter.ai/api/v1; parses OpenRouter's reasoning field). They're available two ways: BYOK (billing the user's own OpenRouter account — separate model keys from the direct zai provider) or, when the shared OPENROUTER_API_KEY is configured, as admin-selectable shared defaults metered like any shared usage. All gateway entries currently ship text-only (supportsImages stays false until a live vision round-trip is verified on the shared key — see specs/026-openrouter-shared-models/promotion-notes.md). Unlike direct z.ai, OpenRouter models get web search via OpenRouter's :online web plugin (wrapped as a function tool, whose sub-call uses a dedicated @ai-sdk/openai client so url_citation annotations map to sources)@ai-sdk/react on the client, ai + @ai-sdk/anthropic, @ai-sdk/google, or @ai-sdk/openai on the server; z.ai and OpenRouter use @ai-sdk/openai-compatible with a custom base URL). Providers are registered in server/api/ai-providers.js. GLM reasoning is streamed to the UI and persisted but stripped from outgoing history (stripReasoningFromHistory) so it isn't fed back as reasoning_contentPOST /api/chat — streams responses to the clientimport_markdown_file; read_document_version left the advertised list — versioned reads go through read_document's versionId parameter) plus the chat-only tools (import_markdown, insert_image, view_image, view_svg_blocks) and web search and web fetch. The script tools (modify, compare_document_versions) expose their full API reference to the chat agent via chatDescription (external MCP clients get a short description instead — see get_tool_documentation below). Web search works for Anthropic, Google, OpenAI, and OpenRouter — Gemini wraps Google Search and OpenRouter wraps its :online web plugin as function tools; Anthropic and OpenAI use their provider-executed web search. Direct z.ai gets web fetch but no web search. Web fetch is universal across all providers<referenced_passages> block — each entry naming its source document and id (rendered as chips in the transcript, not raw markup) so the assistant knows exactly what the user is pointing at and which document it came from, even when a single chat references passages from several documents. No position anchoring — the quote is enough for the assistant to answer or to locate the passage via read_document if it needs surrounding context. Client-only (SelectionChatButton.jsx, pending refs on AiChatContext); the server prompt has a nudge in buildSystemPromptserver/api/chat-errors.js, feature 012) and — since feature 025 — persisted into the transcript itself: the {code, provider, at} failure record is stamped as metadata on the turn's trailing user message in the chats JSONB at the classification point (no separate table). Only classified, in-taxonomy failures are stamped — deliberately out-of-taxonomy paths (a tagged attachment 4xx, a pre-save stream-cap 429, the token-limit/INVALID_ARGUMENT compaction-and-retry interceptions) are not, and a failure before the user message is persisted has no turn to stamp (it surfaces live-only). The client renders every error banner from one durable per-chat turn-error state (fed by live error events and by the loaded transcript) — never from the transport/SDK's transient stream status — so reloads, reconnects, and replays re-surface an unaddressed failure instead of erasing it. A classified failure also tears down its resumable-stream entry immediately (a reconnecting client 204s and derives the outcome from the transcript); the buffered replay-and-tail window applies to turns without a classified failure — a still-recoverable after-content INVALID_ARGUMENT retry keeps its buffer by design. Reconnect-recovery counts as success only when an assistant reply actually lands (a late-landing reply retires a fallen-back banner in the background). The persisted stamp is neutralized by the next user turn (trailing-turn keying — a landed reply clears only the live per-chat entry, while an interrupted partial keeps its notice); no clearing write is ever made to the recordPOST /api/chat/thinking-summary with the accumulated reasoning text and a cheap model condenses it to a short phrase that replaces the label. The summarizer model comes from getThinkingSummaryModels() — an ordered fallback chain over providers holding a shared server key (Claude Haiku 4.5 → Gemini 2.5 Flash → GLM-4.7 via OpenRouter), unmetered and independent of which model/BYOK key powers the chat; the endpoint fails over at runtime with a 5-minute per-candidate cooldown (a configured key can still be unfunded at the provider) and degrades to the static "Thinking" label without ever 500ing or paging the operator — the label is cosmetic (polling starts once ~20 chars of reasoning have streamed; a burst that finishes below that is summarized once on completion, so every block the user watched think ends with a real label). The full train of thought is deliberately not rendered (no expansion); summaries are ephemeral client state, so historical blocks just read "Thinking" after a reload. Works across all reasoning-capable providers: Anthropic (adaptive thinking with display: 'summarized' — required on Sonnet 5/Opus 4.7+ where visible thinking otherwise defaults to omitted; needs @ai-sdk/anthropic ≥ 3.0.94 — and budgetTokens on Haiku), Gemini (thinkingConfig.includeThoughts), OpenAI (reasoningSummary: 'auto', BYOK), and GLM via z.ai / OpenRouter (reasoning_content parsed by @ai-sdk/openai-compatible)modify tool edits a document, a color-coded markdown diff (green additions, red deletions) appears directly in the chat message with expandable sections for large changes. Successful undo/redo tool calls carry the same diff payload — computed server-side from the states immediately bracketing the applied inverse, so after partial supersession it shows only what actually reverted, never a mirror of the original edit — and their tool cards render it with the same DiffView; the diff persists on the tool part, so it re-renders after reload. Honest-empty results (undone/redone: false) carry no diff and stay label-onlymodify diff has an Undo button that reverts the edit via the /api/docs/:id/undo and /redo endpoints. Undo is log-derived and stateless: every content-changing modify records the clock range of exactly its own rows in the yjs_updates log (returned as editRange in the result and stored in the agent_edits table), and the server derives the edit's exact surgical inverse from those logged updates — what the edit inserted comes out, what it deleted comes back, and everything anyone wrote afterwards is preserved (unlike a version restore). The inverse is applied as a normal attributed forward update (history is never rewritten; it shows up in version history like any edit). Because the log is durable and shared, undo works from any server instance, survives session expiry and server restarts, and creates no agent presence session; redo inverts the inverse's own recorded range, so the undo↔redo chain works indefinitely. When later edits have already superseded everything the edit did, the endpoints answer an honest "nothing left to undo" (undone: false + message) instead of pretending. After undoing, the diff is dimmed and marked "Reverted" (kept as a record of what the agent did, not rewritten) and the button flips to Redo; the reverted state is persisted on the chat message (a reverted flag on the tool part) so it survives reloads. The stamp is verified before it is written: the server compares the card's recorded editRange against the record the undo actually inverted and skips the stamp (with a log) on any mismatch — including cards that predate range recording — so "Reverted" never names an edit that was not the one undone. Undo also stays available across reconnects: a sync-resupplied row no longer reads as "an edit still being recorded". The button appears on the latest edit while /api/docs/:id/undo-status (also log-derived — no session lifetime) reports it undoable. Edits made before this mechanism shipped degrade honestly: where the log identifies them unambiguously they undo, otherwise the surfaces report unavailability — never a guessed inverse. The /undo//redo responses additionally carry the revert's diff (same shared handler as the MCP tools), but the button UI doesn't render it — the original modify diff plus the dimmed "Reverted" state already tell the story thereattachment:<key> reference instead of inline base64 (keeps request bodies and stored messages small); the transcript resolves that reference to a short-lived presigned URL via GET /api/chat/attachments/resolve before rendering the <img> (a bare <img src> can't carry the auth token — same pattern as document images in ImageNodeView.jsx). Image attachment is gated on the selected model's vision support: the GLM text models (z.ai + OpenRouter) can't accept images, so the paperclip drops images with a "switch to a vision-capable model" notice when one of them is the active BYOK model (supportsImages on the model registry drives both the client gate and a server-side pre-flight in chat.js that rejects an image on a text-only model with the honest model_no_image_support error, instead of the provider 404-ing the whole turn).md/.markdown file into the chat (or pick it with the paperclip; max 5 MB) and it attaches like any file — shown as a file card on the user's message — but its content never reaches the model: chat.js swaps the file part for a short text note in the model input (replaceMarkdownFileParts) and the assistant imports the bytes from the attachment store into a new document with the chat-only import_markdown tool (title defaults to the file name), then links the result. Detection by extension with mediaType normalized to text/markdown (AiChatInput.jsx); upload rides the standard /api/chat/attachments reference path, which accepts text/markdown alongside the image typesinsert_image — uploads it to S3 and inserts the node), see images already in a document to describe or answer questions about them (view_image — fetches the bytes as vision input), and move/reorder/delete or re-caption existing images via the modify tool. To prevent injecting external/tracking URLs, agent-written image src values must be app image URLs (/api/docs/:docId/images/:imageId); any other src is strippedview_svg_blocks — sanitizes the block's source through the same policy the editor renders with, rasterizes it to PNG server-side via @resvg/resvg-js, and feeds it to the model as vision input). This closes the loop on agent-drawn SVGs: after writing an SVG block with modify, the assistant can look at the actual rendered result and fix visual problems, not just syntax errorsANTHROPIC_API_KEY, OPENROUTER_API_KEY, or GOOGLE_GENERATIVE_AI_API_KEY. Then a user without a complete BYOK setup (key saved, "Use my key" on, model picked) can't use the in-app assistant. GET /api/settings/byok reports this as assistantAvailable: false (hasUsableSharedModel() or isByokActive), and both chat surfaces (side panel and full-page chat) replace the composer with a setup state (AssistantSetupState.jsx): add your own key in Settings (the button opens /settings#assistant-key), a line for admins on setting a server key in .env and restarting with docker compose up -d, and a note that coding agents connected over MCP need no key. The welcome-document kickoff is not sent in this state. Saving a BYOK setup updates the shared BYOK state, so the panel works without a reload. As a backstop, the chat route rejects such a turn with assistant_not_configured (400, fatal, no operator page, reservation released, turn stamped) before any provider callmodify, the result echoes the updated document so the assistant's view stays current without re-reading. Repeated full-document snapshots (from reads and modifies) are deduplicated in context so only the latest is keptmodify that would overwrite those edits is refused and returns the current content so it can reconcile. This also covers the Undo button: an undo is written through the assistant's own identity out-of-band, so the by-author staleness check can't see it. Instead, the persisted reverted flag is read back from the chat history — while a doc's most recent assistant edit is one the user has undone (and it hasn't been re-read since), the assistant is told its earlier edit is no longer in the document and silently re-reads before trusting its cached snapshotai_usage_logai_extra_credits table (one-off credit grants with optional expiration)tsvector) and vector embedding columns (pgvector, gemini-embedding-001, 1536-dim) powering hybrid content search; refreshed by a background indexer as documents change. The full-text row also stores a content_hash (SHA-256 of the extracted body text) that gates embedding regeneration — unchanged text skips the provider entirely, and the hash only advances atomically with a successful chunk swap (or the empty-content chunk cleanup). Chunk rows record embedding_model explicitly; the boot-time repair pass re-indexes documents that are missing, edit-stale, or carry a different model