The release candidate for 1.2.0, server-first.
Up to 1.1.x the browser drove course generation one request per step and kept the courses, the provider keys and the model settings itself. Closing the tab stopped a course halfway, every browser had to be set up on its own, a deployment could offer providers but not fix which models its users get, and the headless API ran a separate pipeline that drifted from the web app. 1.2.0 moves all of this to the server: courses and media, identity, model configuration and course generation live there, and the browser is a client. Generation becomes a server-side run shared by the web app and the API, and models are configured once per deployment. The cost is the deployment shape: a long-running Node process and PostgreSQL instead of a serverless host.
Design: RFC #1754 (release) and #1701 (model configuration). Read Breaking Changes and Before you upgrade first.
Highlights
-
Generation runs on the server. Classic generation is a server-side run that keeps going when the page is closed, resumes from its last checkpoint after a restart or a crashed worker, streams progress to every open tab, and pauses at a failed step so that Retry re-runs only that step. Images and videos are generated alongside the scenes, and a single failed item can be retried. The web app and the headless API share one pipeline. #1756 #1759 #1761
-
Models are configured on the server.
openmaic.ymldeclares providers and capability slots:llmwithcourse.*,classroomandagent, plustts,asr,image,video,webSearchanddocument.slotsare server defaults that users may change.lockfixes slots (orall) for everyone.allowUserKeys: falsekeeps users from adding their own providers.- The settings take the matching shape: set it up yourself, choose a model or configured by the administrator.
- Keys are stored encrypted on the server and never leave it. #1727 #1733 #1767 #1793
-
Materials are parsed as soon as they are attached. The composer uploads and parses each file in place and shows progress, Ready, or Failed with Retry. Generate waits until every file is ready, and the preview then opens without a material-analysis step. Re-uploading the same file reuses the earlier parse. #1796
-
Identity. There are three modes:
- single-user for personal installations (the Docker Compose default);
- anonymous visitors, whose cookie now lasts 400 days and is renewed while in use;
- accounts, through host auth methods.
Anonymous work can be claimed into an account with
POST /api/identity/claim. -
Custom agents are stored on the server (
/api/agents). #1758 -
The database schema is versioned (
openmaic_schema_migrations). #1757 -
Upgrades carry browser data to the server. The first load after the upgrade moves the browser's courses, chat, progress, folders, media, custom agents and model settings to the server, once per browser.
Breaking Changes
-
PostgreSQL and a long-running server are required.
DATABASE_URLis mandatory.- Deploy with Docker (
docker compose upnow includes PostgreSQL and runs single-user, on127.0.0.1) or withpnpm start. - Serverless hosts, Vercel included, stay on 1.1.x; the README explains how to deploy it.
NEXT_PUBLIC_PERSISTENCEandvercel.jsonare removed.
- Deploy with Docker (
-
MODEL_ROUTESwithoutopenmaic.ymlstops the server at startup. Deployments running the Pro agent must writeopenmaic.ymlbefore upgrading. The route-to-slot table is under "Migrating from the legacy configuration" in the configuration docs. Withoutopenmaic.yml, the provider variables,server-providers.yml,DEFAULT_MODELandMODEL_FALLBACKkeep working, but are deprecated. -
The browser no longer sends provider settings. The
x-api-key,x-model,x-base-url,x-model-routes,x-image-*andx-video-*headers, and the matching body fields, are deprecated.- They are honoured only for a slot with nothing assigned.
- They are never honoured under
allowUserKeys: false.
-
Headless API.
POST /api/generate-classroomtakes{ requirement, materialIds? }.- Upload documents first with
POST /api/materials;pdfContentis refused. - Job ids are run ids, so job ids from earlier builds answer
404. - The
enable*,agentModeand web-search request fields are ignored. Every capability the server configures runs, andGET /api/generate-classroom/capabilitiesreports which ones. - Submissions are checked up front (
400 MISSING_MODEL,MISSING_API_KEY, …) and limited per owner (429 ACTIVE_RUN_LIMIT). - In anonymous-cookie mode, keep one cookie jar for the upload, the submission and the polls.
- Upload documents first with
-
Routes removed:
/api/generate/{scene-outlines-stream,agent-profiles,scene-content,scene-actions}/api/extract-document/api/web-searchGET/POST /api/classroom
Use
/api/generation-runsinstead. -
A course is read-only while it is being generated. Other writers get
409 COURSE_GENERATINGuntil its run completes or ends. Deleting the course and moving it between folders stay allowed. -
Runtime sessions are keyed by the resolved owner.
PERSISTENCE_DEV_TOKEN,NEXT_PUBLIC_PERSISTENCE_TOKENandPERSISTENCE_ALLOW_INSECURE_DEV_AUTHare removed.- If the token was your only access gate, put the deployment behind
ACCESS_CODE, a gateway or owner auth methods first. - Earlier runtime sessions are not migrated.
- If the token was your only access gate, put the deployment behind
-
Assets belong to an owner.
ASSET_QUOTA_BYTESis a per-owner limit. -
Interrupted courses are not resumed. Courses interrupted in the browser before 1.2.0 show their remaining scenes as interrupted.
-
Embedders.
@openmaic/storage0.32–0.37 changes:- document ownership moves to the
documentOwnershiprelation; - the browser storage seams have no IndexedDB fallback;
ServerPersistenceProvider.documentStoreis removed.
- document ownership moves to the
Before you upgrade
- Back up the database. The first start runs the schema migrations. On a large
document_stagestable, it builds an index under a write lock; you can build that index concurrently beforehand (see the full notes). - Rolling back to 1.1.x needs either the ownership copy-back SQL (under Breaking Changes in the full notes) or that backup.
- Set
OPENMAIC_SECRET_KEYwhen several replicas share the database, whendata/does not survive a restart, or when the working directory is read-only. Otherwise back updata/instance-secret.keytogether with the database. - Choose an identity mode: single-user, a shared team owner, anonymous, or host auth.
- Keep
data/classrooms. Classrooms stored there are imported once, in the background. - Check which capabilities will run. With the legacy configuration, the first configured provider of each kind becomes the server default. Course research then runs whenever web search is configured, and headless jobs use every configured capability. To turn one off for everybody, set its slot to
nulland list it inlock. - Connect PostgreSQL directly or through a session pool. A transaction-mode pooler (PgBouncer
pool_mode = transaction) is not supported. - Follow the guide. The deployment guide's "Upgrading from 1.1.x" table lists every step.
Known issues
- Web-search services in Settings → Model Services have no Test connection button.
- A token plan connected with an invalid key shows as connected; the error appears only when a course is generated.
- A very short requirement can drift off topic or ignore an explicit slide count.
Full change notes
Breaking Changes
-
Server-backed persistence is always on. Courses, folders and folder membership, chat history and learner runtime, and generated media are stored on the server through
/api/persistence; the browser has no storage backend of its own any more. The build-timeNEXT_PUBLIC_PERSISTENCEswitch is removed (from the client, the Dockerfile anddocker-compose.yml) and ignored. The server now requiresDATABASE_URLand exits at startup without it ([boot] Invalid server configuration; the server will not start: DATABASE_URL is not set. ...);pnpm db:up/pnpm db:downstart and stop a separate development database (the Composepostgresservice definition under its own project and volume,openmaic-dev-db), published on127.0.0.1(OPENMAIC_DB_PORT, default5432), for local development; other deployments pointDATABASE_URLat a PostgreSQL database they run. Only device-local state stays in the browser (settings, playback position, the editor's current scene and undo history, a local cache of media the server already stores, and browser voice profiles), in a newmaic-device-cacheIndexedDB database; Settings → Clear Local Cache clears exactly that. Courses an earlier browser-only build stored in the browser are not deleted; the one-way importer below moves them to the server. -
Docker Compose:
docker compose upnow starts PostgreSQL (no profile) and a server-backed app (DATABASE_URLset fromPERSISTENCE_POSTGRES_PASSWORD) that waits for PostgreSQL to be healthy, in single-user mode (docker-compose.defaults.env, read before.env.localso it can be overridden there). The app port is published on127.0.0.1only; start withOPENMAIC_PUBLISH_ADDRESS=0.0.0.0to serve other machines, and setACCESS_CODEthen: every visitor is the same single owner.OPENMAIC_PORTchanges the host port.--profile server-persistenceis still accepted and does nothing. ADATABASE_URLin.env.localstill wins over the bundled default. Courses an earlier browser-only deployment stored in the browser are not deleted; the one-way importer in this release moves them to the server. Anonymous libraries from an earlier server-backed deployment are not merged into the single owner automatically; claim them explicitly. A.env.localthat setsPERSISTENCE_SHARED_OWNER_IDmust also setOWNER_SINGLE_USER=false. -
@openmaic/storage0.34.0: course ownership is no longer recorded ondocument_stages.PgDocumentStorenever reads or writesdocument_stages.owner_id; an owner-bound store scopes listings, writes, deletes, the freshness manifest and folder membership through the host's ownership relation, named with the newdocumentOwnershipoption ({ table, stageIdColumn?, ownerIdColumn?, tombstoneColumn?, claimOnCreate? }). The relation must cascade with the document rows (REFERENCES document_stages(id) ON DELETE CASCADE) or be cleaned alongside them; a leftover ownership row keeps the id reserved for its owner.documentOwnership: falseturns document scoping off, and on an owner-bound store it requiresallowCrossOwnerDocumentAccess: true, so binding an owner for folders or asset principals cannot silently expose every owner's documents.forOwner(ownerId)(orownerId) withoutdocumentOwnershipnow throws at construction instead of silently scoping nothing. A store that is not owner-bound is tenant-agnostic: it lists and writes every document, where it used to see only documents with no owner.AssetCollector'sassetReferencePrincipalsrequiresdocumentOwnershiptoo, because the backfill reads each document's owner from that relation. A newfolders: falseoption lets a host whose owndocument_stageshas nofolder_idcolumn use the store without folders. Fresh installs no longer get theowner_idcolumn or its two indexes (document_stages_owner_idx,document_stages_owner_folder_idx);document_stages_folder_idxreplaces the latter. -
Server persistence:
stage_metais the only record of who owns a course. At the first start, courses whose owner was recorded only ondocument_stages.owner_idare adopted intostage_meta(once per database, logged with a count; where the two records disagreestage_metastands and the disagreement is counted). To roll back to a release that still reads the column, first copy ownership back for courses created since the upgrade:UPDATE document_stages AS d SET owner_id = m.owner_id FROM stage_meta AS m WHERE m.stage_id = d.id AND d.owner_id IS NULL. -
Server persistence: runtime sessions (
/api/persistence/runtime/*) are keyed by the owner the owner identity seam resolves, not by a client-suppliedx-learner-keybehind the development token.PERSISTENCE_DEV_TOKEN,NEXT_PUBLIC_PERSISTENCE_TOKENandPERSISTENCE_ALLOW_INSECURE_DEV_AUTHare removed and ignored; the browser learns its learner key fromGET /api/persistence/learner-key. Runtime sessions written before this change were keyed by a browser-minted learner key and are no longer reachable; they are not migrated, because trusting a client-supplied old key would restore client-chosen identity. Course documents and media are unaffected. If the development token was your only access gate, put the deployment behindACCESS_CODEor a gateway, or register owner auth methods, before upgrading: without the token the endpoint serves every visitor as their own anonymous owner. -
Server persistence: assets are allocated in a per-owner partition, so
ASSET_QUOTA_BYTESis a per-owner ceiling and only the owner can replace or delete an entry. Other owners read an entry by id while a live course of the entry's owner references it, and a document write references and commits only its owner's entries (and legacy ones), so naming another owner's id records nothing. Entries in the old shared partition stay readable by id, and can be replaced or deleted only by an owner who owns every course referencing them. -
Server-side classroom generation (
POST /api/generate-classroom) writes through server persistence. A job runs for the request owner and saves the classroom into that owner's course library, with its images, videos and narration in the owner's asset pool; nothing is written underdata/classroomsordata/classroom-jobsany more.POST /api/classroomandGET /api/classroomare removed, and so is the classroom page's fallback toGET /api/classroom. Jobs are stored in PostgreSQL (as generation runs, see below) and are owner-scoped:GET /api/generate-classroom/<jobId>answers only the owner that created the job (or the account it was claimed into) and404for everyone else, like an unknown id. With a fixed owner (PERSISTENCE_SHARED_OWNER_ID,OWNER_SINGLE_USER, or a host auth method) this is automatic; in anonymous-cookie mode a headless caller must keep the cookie from its first response and send it when polling, and the classrooms it generates belong to that anonymous owner: anyone can open them by link, but no browser user can edit them. Run with a fixed owner or host auth when API-generated courses should be editable. -
Headless generation runs on the server's generation runs (#1754, E2):
POST /api/generate-classroomstarts a run of the request owner, the same pipeline (and the same step functions, prompts and validation) as classic generation, with the outline confirmed by the run itself, course-specific agents (the built-in ones if that step fails), and no interactive or task-engine mode. The separate server pipeline (generateClassroom, its media and TTS passes and the in-process job runner) is removed. For API callers:- The job id is the run id (
run-…), also returned asrunId; job ids of earlier builds answer404. The poll response keeps its shape (statusqueued/running/succeeded/failed,step,progress,scenesGenerated,totalScenes,result.classroomId/url/scenesCount,error,done), read from the run, and addsrunId,runStateandretryable. A run paused at a failed step, and a run whose course was deleted or that was discarded, read asfailedwitherrornaming the step; a paused one isretryableand keeps its scenes, andPOST /api/generation-runs/<runId>/retry({ commandId }) resumes it at that step, after which the job readsrunningagain.stepno longer reportsgenerating_ttsorpersisting(narration is part of each scene, and each scene is saved as it is generated); it addsgenerating_mediafor the images and videos that finish after the last scene. - A submission is refused up front, before any run exists, instead of becoming a job that fails:
400 MISSING_MODELwhen the outline or actions slot, or every scene content type (course.content.<type>, which inheritscourse.contentandllm), resolves to no model (none configured, or the slot turned off),400 MISSING_API_KEYwhen its provider needs a key it lacks,400 INVALID_URLfor an endpoint the workspace may not use,400 MODEL_CONFIG_INVALIDfor an option only the deployment may set (Bedrock, a proxy). Bodies are capped at 64 KiB (413) and requirements at 20 000 characters. The checks run in this order: the body, the models, the materials, the limit. When only some scene types resolve, the submission is accepted: the outline does not avoid the others, and a scene of such a type pauses the run at its content step. - Runs are limited per owner:
429 ACTIVE_RUN_LIMITwhen the owner already hasOPENMAIC_MAX_ACTIVE_RUNS_PER_OWNERruns in progress (default 2). A paused run, like one waiting for its outline to be confirmed, holds no worker and does not count; a step Retry makes it count again and answers the same429over the limit (asconfirm-outlinedoes). - Courses are written as they generate: the course exists (read-only to other writers) from its first scene, each scene is narrated before it is appended, and images and videos are generated alongside. A narration provider failure the retries cannot overcome pauses the run instead of leaving clips silent; a clip the asset store refuses is left silent and counted in
result.warning, as is a failed image or video, which does not fail the job either (the retryable ones have a Retry on the run). The counts survive the compaction of finished runs.result.ttsCoverageis removed, and so areTTS_MIN_INTERVAL_MSandTTS_BACKOFF_BUDGET_MS, which only the removed TTS pass read. - Document images found by material extraction are stored as assets of the course and can be placed on slides; the removed pipeline used the extracted text only.
- The
generate-classroomLLM stage key is removed (nothing resolves through it); anx-model-routesheader naming it gets the unknown-stage warning, and theMODEL_ROUTESmigration table keeps its row. - Pre-release builds of this line created a
classroom_generation_jobstable (schema storeclassroom-generation-jobs); it is no longer created or read, and is left untouched in databases that have it.
- The job id is the run id (
-
Deployment: OpenMAIC needs a long-running Node.js process with PostgreSQL, the Docker image (
docker composeor on its own) orpnpm start(#1754, F). Generation runs execute in a worker inside the server process and continue after the request that started them, so serverless hosts, Vercel included, are not supported.vercel.jsonis removed, and the build always produces the standalone output (theVERCELbuild switch innext.config.tsis gone). Serverless deployment is supported up to 1.1.x: the README describes deploying therelease/1.1.xbranch on Vercel (Vercel's deploy button cannot select a branch whose name contains a slash), and such a deployment moves to a long-running host without losing data when the new host uses the same database and address. The deployment docs gain the required configuration (DATABASE_URL, an identity mode, and for several instancesOPENMAIC_SECRET_KEYand one shared data directory, where uploaded materials live), the generation run variables and an "Upgrading from 1.1.x" table. -
Model configuration is server-side (#1701): models and providers come from
openmaic.ymlor the settings (Token Plan, Model Services and Course Model Config, see Features), not from the browser.MODEL_ROUTESis no longer read (see below); withopenmaic.ymlit is ignored with a notice. Thepbl-chatandmaic-agentstage keys, which nothing resolved, are removed. The provider variables,server-providers.yml,DEFAULT_MODELandMODEL_FALLBACKkeep working while there is noopenmaic.ymland are deprecated; withopenmaic.ymlthey configure nothing on their own and are referenced from the file as${VAR}. #1731 #1732 #1735 -
MODEL_ROUTESwithoutopenmaic.ymlstops the server at startup (MODEL_ROUTES does not carry over to the model configuration: ...). Earlier releases requiredMODEL_ROUTESfor the agent runtime (amaic-agent-driverroute), so every deployment that runs the Pro agent must migrate before upgrading: writeopenmaic.ymland removeMODEL_ROUTES. Each route becomes the slot of its stage:maic-agent-driver→agent(itsapiandcontextWindowmove to the slot; a thinkingeffortis refused there as before),conversation-title→agent.title(unset, it followsagent),scene-outlines-stream→course.outline,scene-contentandscene-content:slide|quiz|interactive|pbl→course.contentandcourse.content.slide|quiz|interactive|pbl,scene-actions→course.actions,agent-profiles→course.agents,web-search-query-rewrite→course.research,chat-adapter,quiz-gradeandpbl-v2-runtime(with its:instructor,:open-task,:evaluate,:simulatorvariants) →classroom,generate-classroom→llm. Stages that share a slot take one model. A route'sthinkingmoves to the slot. See "Migrating from the legacy configuration" in the configuration docs for a worked example. -
Model configuration: the browser holds no provider state and sends none. Providers, keys, endpoints, model and provider ids, thinking settings, per-stage routes, Token Plan enrollment and per-capability selections are removed from the settings store; the Token Plan, Model Services and Course Model Config sections read and write the workspace's configuration on the server instead (see Features); the client no longer sends
x-api-key,x-base-url,x-model,x-model-routes,x-image-*,x-video-*,x-*-provideror the key, endpoint, provider and model body fields. A browser's own settings are imported once (see Features). #1735 #1737 #1744 #1767 -
Deprecated request fields:
x-model,x-api-key,x-base-url,x-provider-type,x-model-routes,x-image-provider,x-image-model,x-video-provider,x-video-modeland the equivalent body fields of the speech, search and document routes are honoured only while the slot they would affect has nothing assigned or, on a server still configured through the legacy variables, only a default those translate to (a default inopenmaic.yml, a workspace's own choice and a locked slot win, and a slot set tonullrefuses the request), and not at all underallowUserKeys: false, which also refuses the header form of the provider test routes. The server logs a warning the first time a request names its own language model. They will be removed two minor versions after the model settings ship. -
POST /api/generate-classroomtakes{ requirement, materialIds? }. Documents are uploaded first withPOST /api/materials(X-Material-Filename, the bytes as the body) as the same owner (in anonymous-cookie mode, keep one cookie jar for the upload, the submission and the polls), referenced by id, and deleted with the newDELETE /api/materials/<id>once the job is finished.pdfContentis refused with400 INVALID_REQUEST;enableWebSearch,enableImageGeneration,enableVideoGeneration,enableTTS,agentMode,webSearchProviderId,webSearchApiKey,webSearchModelIdandbaiduSubSourcesare removed and ignored. Web search, images, video and narration run whenever the server configures them, and the newGET /api/generate-classroom/capabilitiesreports what is configured and which upload types this server can extract.materialIdstakes at most 5 ids, 150 MiB in total, of types an available extractor reads, and unknown, foreign, unfinished and deleted ids share one answer; each owner's material library is capped (100 files / 2 GiB by default). The server never fetches caller-supplied URLs. The job record'sinputSummary(requirementPreview,hasPdf,pdfTextLength,pdfImageCount, kept in the olddata/classroom-jobsfiles and never part of the poll response) is removed. Uploads and materials are owner-scoped like jobs. #1728 -
Generation runs on the server (see Features): a run keeps going when the page that started it is left or closed, and once its outline is confirmed it has no stop or pause; deleting the course ends it at its next step. A course is read-only to every other writer until its run completes or ends: content writes through the document store (
saveDocument,putStage,putScene,deleteScene) andgeneration-completeanswer409 COURSE_GENERATINGon the stage routes,403with that code on/api/persistenceand an error to the Pro agent's tools; deleting the course and moving it between folders stay allowed. A run paused at a failed step keeps its course read-only until a Retry completes it or the course is deleted. #1759 #1761 -
The per-step generation routes the browser called in 1.1.x are removed; course generation runs on the server (#1754, E4). Start a run with
POST /api/generation-runs(orPOST /api/generate-classroomfor a headless caller) and follow it instead:POST /api/generate/scene-outlines-streamis removed; use generation runs.POST /api/generate/agent-profilesis removed; use generation runs.POST /api/generate/scene-contentis removed; use generation runs.POST /api/generate/scene-actionsis removed; use generation runs.POST /api/extract-documentis removed; use generation runs (upload materials withPOST /api/materials).POST /api/web-searchis removed; use generation runs.
-
Courses interrupted in the browser before 1.2.0 are not resumed; their remaining scenes show as interrupted ("Generation was interrupted", with no Retry). The scenes they have keep working, and images and videos of those courses that were never generated are offered as Retry rather than generated on open.
-
Embedders: the browser storage seams (
configureDocumentStorage,configureRuntimeStorage,configureAssetPoolStorage) have no IndexedDB fallback any more. The browser bootstrap configures the HTTP stores; code outside the browser must configure astore, and resolving an unconfigured seam throws. #1710
Deprecated
document_stages.owner_id: the document store no longer reads or writes it (only the boot backfill reads it, and a claim mirrors the new owner into it while it exists, so a rollback still finds consistent ownership). Existing installations keep the column (made nullable, its default removed) for this release so a rollback still finds it; it will be dropped in the next release.
Upgrade notes
- Anonymous identities: existing 30-day
anonymous_idcookies are renewed to 400 days on their next API request, and every response of an owner-scoped route that resolves to an anonymous cookie now carries aSet-Cookierenewing it. A browser that loses the cookie (a manual clear, or 400 days without use) gets a new owner, and cannot reach the earlier anonymous library, or a pending legacy import bound to it, from that browser: anonymous identity has no other key. Use single-user mode (the Compose default) for a personal installation, or accounts (owner auth methods) for many users. - Schema versions: every start now records which schema version each store of the database is at, in a new
openmaic_schema_migrationstable, and applies only what is missing, in order, under the existing bootstrap lock (waited on for at most five minutes, then the start fails naming the lock). One-time steps (thestage_metaownership adoption, thedocument_stages.owner_idretirement, the owner-materialasset_iddrop, the agent-session owner-event constraint swap) run once per database instead of on every start. The first start on an existing database (1.1.x or a development build) runs every store's baseline statement by statement, outside a transaction, exactly as earlier starts ran it (the same locks, released as each statement finishes), and records it afterwards; a start interrupted part-way runs it again. A migration that fails is reported with its store, version and name and retried on the next request. The server exits at startup ([boot] Server startup failed ... SchemaVersionAheadError) if the database records a version of any store, including those of features not in use, that is newer than the release knows: it was upgraded by a newer release, so run that release or a later one, or restore a backup taken before it. Releases before 1.2.0 do not read the table; rolling back to one still requires the ownership copy-back above (courses 1.2.0 creates have nodocument_stages.owner_id), otherwise restore a backup taken before the upgrade. A changed checksum of an applied migration only logs a warning in production (and stops a development or test server). Schema bootstrap needs a direct or session-pooled PostgreSQL connection; a pooler in transaction mode (PgBouncerpool_mode = transaction) is not supported. - The first start creates the
owner_mergestable (with thestage_metaand owner-material schemas). Nothing is claimed until a claim is triggered; the default trigger is the explicitPOST /api/identity/claim. - The first start on an existing installation changes
document_stagesunder table locks.CREATE INDEX document_stages_folder_idxbuilds the new index under a SHARE lock, which blocks writes todocument_stagesfor the length of the build.DROP INDEXofdocument_stages_owner_idx/document_stages_owner_folder_idxtakes a brief ACCESS EXCLUSIVE lock. If a host addedNOT NULLor a default toowner_id,ALTER TABLE ... DROP NOT NULL/DROP DEFAULTalso takes ACCESS EXCLUSIVE, which blocks reads too and waits behind any open transaction on the table. Upgrade in a quiet window, or start one instance first and let it finish before the rest start (concurrent starts are serialized anyway; see Bug Fixes). Once done, later starts change nothing. - To keep the index build off the start path on a large table, create the index and drop the old ones yourself beforehand, outside a transaction:
CREATE INDEX CONCURRENTLY IF NOT EXISTS document_stages_folder_idx ON document_stages (folder_id, id) WHERE folder_id IS NOT NULL;,DROP INDEX CONCURRENTLY IF EXISTS document_stages_owner_idx;,DROP INDEX CONCURRENTLY IF EXISTS document_stages_owner_folder_idx;. The start then builds and drops nothing. It still takes and releases the same momentary SHARE lock that every otherCREATE INDEX IF NOT EXISTSin the schema takes on each start. - Classrooms an earlier version stored under
data/classrooms(orOPENMAIC_CLASSROOMS_DIR) are imported once, in the background after startup, retried with backoff until the import completes: each keeps its id (its link shows not-found until its import lands), its own/api/classroom-mediamedia and narration move to the asset pool, and the outcome is recorded in the newlegacy_classroom_importstable, so later starts skip it. An id another course already holds is skipped and that course stays as it is; so are invalid files and reservation placeholders, decided before any media is stored, and creates the host refuses, whose stored media is released. When the owner's asset store is full the run stops, records nothing for the classrooms it did not import, releases what it had stored for the current one while it is still unclaimed, logs it, and tries again later. A classroom whose import fails otherwise is retried and, after three failed attempts, skipped with its last error. One instance imports at a time. Imported classrooms belong to the shared team owner or the single user when one is configured, otherwise to a dedicated owner (system:legacy-classrooms): they open read-only from their old link and appear in no visitor's library. The files are not deleted; keep the directory, since imported classrooms may still reference other classrooms' files and agent-run courses still serve media from it. - Rolling upgrades: jobs an older instance recorded under
data/classroom-jobsare not migrated, and polling them answers404. An older instance still running during the upgrade may finish a classroom the importer had skipped as a reservation placeholder; that file is not imported later. - Media capabilities after an upgrade with the legacy configuration: the first configured TTS, ASR, image, video and document provider, and the first search provider in the old priority order, become server defaults, so these capabilities are on wherever the server configures a provider. This keeps the earlier behaviour of the web app, which switched image, video and narration on at a browser's first visit whenever the server had a provider for them, and gave the classroom chat and the agent web search whenever a search provider was configured. Two things change: course generation now researches the web whenever web search is on (it was an opt-in switch, off by default), and headless
/api/generate-classroomjobs use every configured capability (the request flags defaulted to off). A workspace switches any of them off in Settings → Course Model Config; a deployment that wants one off for everybody sets its slot tonullinopenmaic.ymland names it inlock. - Settings stored in a browser by an earlier version (providers with their keys and base URLs, the chosen model, token plan enrollment and each capability's selection) are imported into the workspace once, never replacing a workspace setting or a slot in a subtree
openmaic.ymllocks. Not carried over, to set up again: per-stage models, thinking settings, custom speech and transcription providers, AliDocMind's key pair, the VoxCPM backend and Baidu search sub-sources (the custom providers and the key pair stay in the browser with their keys, listed in Settings → Model Services). Speech input switched off becomesasr: null, and narration, images or video switched off while a usable provider for them was set up becometts: null,image: nullorvideo: null. The research switch is not carried over (it only stopped course research; chat and the agent kept searching), nor is a switch that was off only by default: each capability now runs whenever its slot resolves. - Keys saved in the model settings are encrypted under
OPENMAIC_SECRET_KEY, or, when it is unset, under a secret generated on first use indata/instance-secret.key. SetOPENMAIC_SECRET_KEYwhen several replicas share the database, whendata/does not survive a restart (a container without a volume) and when the working directory is read-only (saving a key fails there otherwise); back up the secret file with the database otherwise. Keys sealed under a lost or different secret cannot be read (the settings mark them) and have to be entered again. #1733 #1783 The server warns at startup whenOPENMAIC_SECRET_KEYis unset and the data directory is not writable, and when stored keys were sealed under a different secret than the current one; it still starts.
Features
-
One-way import of browser data from earlier builds (temporary). On the first load after the upgrade, once the page is idle, the client moves what an earlier build kept in the browser to the server, for the owner the server resolves: courses from the browser document store and the original tables with their chat, learner runtime, playback position, agent roster, folders and membership, pre-runtime quiz state and media; media bytes of server courses from earlier opt-in server builds that only the old browser tables hold; and device-only rows (generation failure records, bytes a full store refused, auto-voice reference clips) into the device cache. It runs once per browser: the server binds the browser's random id to the first owner that asks (the new
legacy_import_bindingstable andPOST /api/identity/legacy-import-binding, an atomic insert that answers only whether the requesting owner holds the browser), a claim carries the binding to the account (a new claim participant), and owner resolution refuses every importer request (X-OpenMAIC-Legacy-Import) of an owner that does not hold the binding with409 LEGACY_IMPORT_NOT_BOUND; any other owner later using the same browser gets nothing imported. It is automatic and silent (problems are logged under[legacy-browser-import]), never writes to the old browser storage, keeps one ledger per browser (a random browser id and no owner information) so it resumes after an interruption and never imports twice (Clear Local Cache keeps it, and keeps the pre-runtime quiz keys until the import is complete), and serializes tabs with Web Locks. A course the owner already has on the server stays as the server has it; one whose id another owner holds is imported under a new id derived from the browser id; one deleted on the server stays deleted. Transient failures and 401s are retried on later loads with backoff; when the asset store is full the course is imported anyway and its media waits where the app's own retry finds it; media the server refuses for good shows the ordinary failed-media state (without Retry when nothing could regenerate it). A course the old browser storage cannot read holds up only what it could affect, and settles after five failing runs over a day (the older table copy when there is one, otherwise skipped with the reason). A browser whose data another owner holds asks the server again only every ten minutes, and opens the old databases only once it is bound. The module (lib/legacy-browser-import/) will be removed a few releases later; its README lists the steps. -
Owner identity: a built-in
singleUserowner for personal installations.OWNER_SINGLE_USER=trueresolves every request to one owner (OWNER_SINGLE_USER_ID, defaultlocal) withkind: 'user'andcourse:publish, minting no anonymous cookie; the principal gets a claim candidate from an anonymous cookie the browser still carries, so earlier anonymous work can be claimed into it explicitly (POST /api/identity/claim;OWNER_CLAIM_TRIGGER=autowould merge every visiting browser's anonymous library, so it is not a default). It runs with or withoutACCESS_CODE; without one the server logs one prominent startup warning (in place of the generic unset-ACCESS_CODEwarning) that anyone who can reach it shares, edits and can delete the single library. It excludesPERSISTENCE_SHARED_OWNER_ID; hosts may registersingleUserAuthMethod()last, under the same rules assharedTeamAuthMethod(). Malformed values fail startup. The app warns at startup when published beyond loopback (OPENMAIC_PUBLISH_ADDRESS, which the Compose file passes to it) with the Compose default PostgreSQL password. -
Owner identity: one resolution entry point with composable auth methods. A host registers an ordered list of
OwnerAuthMethods once frominstrumentation.tswithconfigureOwnerAuthentication({ methods, anonymousFallback? })(single-shot, sealed on first use, validated at boot). Each method answersauthenticated,not-applicable(no credential of its kind) orinvalid(its credential is present but bad); core keeps the firstauthenticatedanswer, refuses anyinvalidanswer with401 INVALID_CREDENTIALwithout asking later methods, and when no method applies falls back to the anonymous cookie owner, or answers401withanonymousFallback: false. Route handlers and Server Actions ask the same methods in the same order, once per request.PERSISTENCE_SHARED_OWNER_IDstill selects thesharedTeamowner on its own; beside host methods it is kept by listingsharedTeamAuthMethod()last, and setting the variable while a registration leaves it out fails startup. Identity gateways are covered by a documented recipe (a host method that verifies the gateway's signed JWT against the identity provider's keys), not by a built-in. Host methods live inlib/server/identity/host/, the only place the boundary test lets code read gateway identity headers or an incomingAuthorizationheader. On403 OWNER_RETIRED, core callsclearCredentialonly on the anonymous fallback and on methods that declareissuesAnonymousOwners: true, never on an account method. The unreleasedOWNER_AUTHENTICATOR/TRUSTED_PROXY_*variables of an earlier built-in gateway-header authenticator fail startup when set. -
Owner identity: claiming anonymous work into an account. When a host auth method authenticates a non-anonymous principal and the same request also carries a valid anonymous owner cookie, core attaches a
pendingClaimnaming that anonymous owner (never for an anonymous principal or the built-insharedTeam, and a method cannot set one itself), which covers both an anonymous-then-sign-in flow and a deployment moving from anonymous use to accounts.POST /api/identity/claim(same-origin JSON only;OWNER_CLAIM_TRIGGER=autoclaims on the first request carrying a pending claim, except the explicit claim routes) moves the anonymous owner's folders, courses, materials, agent sessions, skills, runtime sessions and asset entries to the account in one transaction, records it in the newowner_mergestable, and drops the anonymous cookie. Same-named folders merge, a folder id the account already uses is renumbered, a colliding skill handle gets the first free numeric suffix, runtime sessions are re-keyed as stored, and quotas are not applied to what moves. Claims are idempotent per pair, refused for an anonymous owner another account already claimed, and never chain. A retired anonymous id writes nothing afterwards: creates and writes through/api/persistence, the folder, course, material and skill routes, and writes by id to moved rows answer403 OWNER_RETIRED, with aSet-Cookiethat drops the retired cookie. Agent runs, generated media and skills created by a run that started before the claim follow it to the account. Every owner write takes a per-owner advisory lock first, so a fenced write racing a claim is moved or refused; lock waits are bounded (OWNER_CLAIM_LOCK_WAIT_MS,OWNER_WRITE_LOCK_WAIT_MS) and running out answers503 OWNER_BUSYwithRetry-After. The anonymous cookie is a bearer credential: its holder can claim that anonymous work into a signed-in account, so clear it on shared devices. Hosts add their own tables withregisterClaimParticipant, and an auth method may implementdescribeStoredOwnerandclearCredential;principalFromStoredOwner(ownerId)describes a stored id for work without a request. The runtime contract'sPOST /runtime/learners/mergenow performs the same claim, allowed only from the request's own pending claim. The owner-events stream tells a retired owner only that it moved, never the account's id. -
@openmaic/storage0.37.0: versioned schema migrations for the PostgreSQL backends. Each backend declares an ordered list (DOCUMENT_PG_MIGRATIONS,RUNTIME_PG_MIGRATIONS,ASSET_PG_MIGRATIONS,AGENT_SESSION_PG_MIGRATIONS,AGENT_SESSION_MATERIAL_PG_MIGRATIONS,USER_SKILL_PG_MIGRATIONS) whose version 1 is the DDL earlier releases ran on every start, run statement by statement outside a transaction, andapplySchemaMigrations(queryable, { store, migrations }, { lockTimeoutMs? })(@openmaic/storage/pg-migrations) applies the pending ones under a session-level advisory lock waited on for a bounded time (SchemaLockTimeoutError), each later migration in a transaction with its record unless it setstransaction: false, recording(store, version, name, checksum, applied_at)inopenmaic_schema_migrations. Theensure*Schemafunctions keep their signatures and now run their store's migrations: they are safe to call from several instances at once, accept a pool, a checked-out client, a connectedpg.Clientor PGlite, and refuse a connection inside an open transaction (SchemaMigrationInTransactionError) instead of committing the caller's work. A database that records a newer version of a store than the code knows is refused withSchemaVersionAheadError; a changed checksum of an applied migration throwsSchemaMigrationChecksumErroroutside production and warns underNODE_ENV=production.verifySchemaMigrations(queryable, sets)makes both checks read-only. One-time steps now run once per database: thedocument_stages.owner_idretirement (relaxingNOT NULL/ default, dropping its two indexes) is document version 2, and the owner-event type constraint swap is agent-session version 2, soDOCUMENT_PG_SCHEMAandAGENT_SESSION_PG_SCHEMA, now every migration's SQL concatenated, list those statements last. A table-name override is recorded as a store of its own.splitSqlStatementsno longer drops a last statement that has no terminating semicolon. #1757 -
@openmaic/storage0.36.0:PgAssetStore.releasePending(principal, ref)deletes an entry only while it is still an unclaimed allocation (the principal's, pending, and named by no document), and answers whether it did. It locks the pending entry and then checks references in a separate statement, like the collector's entry pass, so a reference a concurrent backfill commits is never cascaded away. A writer that allocated ids and then failed to write the document naming them can release them at once without risking an entry a document did commit. -
@openmaic/storage0.35.1:reassignDocumentFolders(tx, { fromOwnerId, toOwnerId, documentOwnership })moves one owner's folders and filing to another (merging same-named folders, renumbering colliding ids);PgAssetStore.reassignPrincipal(fromKey, toKey)moves a principal's entries under both principals' write locks;PgUserSkillStore.mergeOwner(from, to)moves skills, renaming a handle the target holds to one neither side holds;PgRuntimeStore.reassignLearner(from, to)re-keys sessions without re-validating them.PgUserSkillStore(resolveFinalOwner) andPgRuntimeStore(resolveFinalLearner) take a hook that runs as the create transaction's first statement. Every HTTP handler (runtime, documents, assets) now answers aDocumentWriteRefusedErroras403with its code, and the newStorageBusyError(code, message, retryAfterSeconds)as503with its code andRetry-After.PgUserSkillStore.listorders ties by id. -
Server persistence: host extension hooks, registered once from
instrumentation.tsnext to the owner auth methods and sealed on first use.configurePersistenceHooksaddsauthorizeCreate/onCreate(run once per created course inside its transaction; a refusal answers403 CREATE_REFUSEDand a throw rolls the create back), alibraryprovider for whatGET /api/stageslists (never an id the read path would refuse), andbeforeAssetAllocate(refuse an upload,createorreplace, with anyResponsebefore bytes are stored or quota counted). Hooks receive anactorwhosesourceis'request'(with the resolved principal) or'background'(an agent run; a refusal reaches the agent as a fixed message).configureAssetByteStorereplaces theASSET_S3_BUCKETswitch for the request path and the asset collector alike; underASSET_BYTE_EGRESS=redirecta store that does not declaresignsReadUrlsstops the server at boot. Nothing changes when no hook is registered. -
@openmaic/storage0.33.0:DocumentWriteRefusedError(stageId, code, message)lets a store refuse a write as policy; the document HTTP handler answers403with the error's code and applies nothing.isDocumentWriteRefusedErroralso recognizes one thrown by another copy of the package, by name and shape. -
Server persistence:
ServerPersistenceProvider(lib/persistence/server-provider.ts) no longer exposes an unscopeddocumentStore, which wrote courses with no owner check and no host hooks. Use the owner-bound store (createOwnerBoundDocumentStore, orgetOwnerScopedDocumentStore). -
Owner identity:
POST /api/chat/pianswers401when owner resolution refuses the request, like every other owner-resolving route, instead of continuing without an owner. Under the default anonymous fallback nothing changes. -
Agent registry on the server (#1754, G). Built-in agents stay in code (read-only, updated and translated with releases); an owner's custom agents are stored in a new owner-scoped
owner_agentstable (schema storeowner-agents) instead of the browser'sagent-registry-storage.GET /api/agentslists the built-in agents (readOnly: true) and then the owner's own;POST /api/agentscreates one andPUT/DELETE /api/agents/:idchange or delete one, checked with the same schema the browser uses (at most 100 per owner); built-in agents answer403 BUILT_IN_AGENT_READ_ONLY. A claim moves the anonymous owner's agents to the account, which keeps its own agent where both use an id. On the server,resolveAgentsForOwner(ownerId, agentIds)(lib/server/agents/registry.ts) resolves ids to agents and throwsUnknownAgentsErrorlisting the unknown ones. The browser registry reads and writes through the API and no longer writes localStorage; the custom agents an earlier build kept there are imported in the background, through the legacy importer's binding and fence (POST /api/agents/import), and the import stays open (the browser copy kept, Clear Local Cache included) until every one of them is on the server. #1758 -
Capability slots (#1701): every model and provider call resolves through one slot of a fixed tree:
llmwithcourse.research,course.outline,course.agents,course.content(andcourse.content.slide/quiz/interactive/pbl),course.actions,classroomandagent(requires tool calling) withagent.title(openmaic.ymlonly); and the rootstts,asr,image,video,webSearchanddocument. A slot without an assignment follows its parent;nullturns a capability off for its subtree (whatever a request names); a root with nothing assigned fails loudly instead of falling back to any vendor. A slot in a subtreeopenmaic.ymllocks resolves from the file alone; any other slot takes the workspace's own assignment found anywhere from the slot up to the root, and only without one the server's default (slotsinopenmaic.yml, or the translatedDEFAULT_MODELand friends), so whatever a user changes wins and a slot that follows its parent follows the user's choice there. Resolution runs at request time for the request's owner and in background work for the owner it works for (following a claim). Requirements are checked on the slot that declares them. Language-model slots take afallback, used bycallLLMand, as the last attempt before anything has streamed, by streaming calls; a slot-resolved model no longer retries onMODEL_FALLBACK, and a fallback on any other slot is refused. Browserless outlines resolvecourse.outlineinstead of thegenerate-classroommodel. Media, search, document extraction, narration and the agent runtime's tools resolve through their slots too, keeping the<CAP>_<VENDOR>_ENABLED=falseswitches in force. #1726 #1729 #1734 #1735 #1737 #1739 #1743 -
openmaic.yml: the deployment's model configuration, read at startup from the working directory (/appin the Docker image) or the fileOPENMAIC_CONFIGnames (which must then exist). It declaresproviders({ preset, apiKey, baseUrl, proxy, credentials, options }, from built-in presets including the four token plans andopenai-compatible),slots, the server's defaults that users may change in the model settings (provider:model, a provider-only reference for search and document services,null, or{ model, thinking, fallback, api, contextWindow }),lock([slot, ...]orall: slots fixed for everyone, each with its whole subtree; every slot listed must be written underslots) andallowUserKeys(defaulttrue;falsekeeps users from adding providers, keys and token plans and stops using the ones they added). Apolicykey is refused, namingallowUserKeys.${VAR}is interpolated in every string and an unset or empty variable is an error; the schema is strict and every problem is reported at once by its path, and an invalid file stops the server at startup. Without the file, the provider variables,server-providers.yml,DEFAULT_MODELandMODEL_FALLBACKare translated at startup into providers and defaults (DEFAULT_MODELforllm; the provider the server used to pick, orDEFAULT_IMAGE_PROVIDER, for the media, search and document roots) with a deprecation notice. A newopenmaic.example.yml; Docker mounts the file at run time and.dockerignorekeeps it out of images. The configuration docs are rebuilt around it, in every locale. #1727 #1731 #1732 #1737 #1742 #1744 #1750 -
Model settings on the server: Token Plan, Model Services and Course Model Config read and write the workspace's model configuration through the new
GET/PUT /api/model-config(one change per request against arevision;409 CONFLICT,409 SLOT_LOCKEDfor any slot in a locked subtree). The view carries each slot'ssource(default,workspace,locked,inheritedwith the slot it follows, orunconfigured), whether it islocked, theserverDefaultwritten on it, and the deployment'sallowUserKeys. The settings take one of three shapes derived from it: set it up yourself (allowUserKeyson: Token Plan, Model Services and Course Model Config), choose a model (allowUserKeys: false: only Course Model Config, among the deployment's providers) and configured by the administrator (everything locked: the course model diagram, every card read-only). A control is shown only where using it can change something: a Model Services tab and adding a service only while its capability has a slot a user may set, Token Plan only while a plan can fill a slot that is not locked (connecting never touches a locked one), a locked card as one read-only line marked Fixed by the administrator, a card on a server default marked Server default with Reset to server default once changed, and the home page model picker only when the default model can be changed (llmnot locked, a language model to pick). Model Services keeps one tab per capability with its services: a service's key (write-only, shown masked), endpoint (chat services only) and model list are its workspace provider, and the deployment's services are shown read-only. Course Model Config is the course model map: each slot with its effective model and where it comes from, edited on the card (follow the parent, a model, off, a fallback, the thinking settings of a chat slot's own model); a capability's switch sets its slot tonulland restores what it held. Connecting a token plan adds its provider and applies its recommended configuration (asking first before it replaces assignments the workspace made); disconnecting removes it. Test buttons and model lists name the saved provider (provider/modelon the verify routes andprobe-models,previewProvider/previewModelon speech and transcription), so no key leaves the browser. A workspace's settings are stored per owner in PostgreSQL with keys encrypted at rest (AES-256-GCM under the instance secret) and move with a claim. Workspace providers may not use Bedrock, a proxy or a key pair; a custom base URL is for chat presets only, validated as a caller-supplied endpoint; media, search and document services use their preset's public endpoint, except Azure Speech's official regional endpoints. The generation toolbar's model picker sets the default model (llm) with its thinking settings, which every slot that follows it uses (stages with a model of their own are shown in Course Model Config); the material popover's extractor select sets thedocumentslot. #1733 #1740 #1741 #1744 #1767 #1780 #1781 #1784 -
One-time import of browser model settings (temporary): the settings store's migration turns what a browser kept (providers with keys or custom endpoints, enrolled token plans, the chosen model, per-capability selections, an explicit "speech input off") into a proposal for
POST /api/model-config/import, staged before any field is dropped and sent through the legacy importer's binding. The import adds only what the workspace and the deployment do not already have and lists what it skipped; per-stage routes and the old per-browser media toggles are not imported. A setting leaves the browser only once the answer shows the workspace holding it (a provider only when the server holds the same key); anything skipped, refused or unconfirmed stays in the browser with its keys and is listed in Settings → Model Services until it is set up again or discarded (see Upgrade notes). #1744 #1784 #1785 #1787 -
Generation runs (#1754, E): a run is an owner-scoped PostgreSQL record (new schema store
generation-runs: runs, per-step checkpoints, an ordered event log and idempotent commands) that executes the classic pipeline with the step functions shared with the routes (research, outline, agents, then per scene content, actions and narration) and commits a checkpoint after every step. A worker runs in every server process, whether or notOPENMAIC_AGENT_RUNTIME_ENABLEDis set; another process takes a run over from its last checkpoint when its worker dies, and a step whose workers keep dying pauses the run. States:preparing → outlining → awaiting_outline_confirmation → generating → completed, pluspaused(a step failed after its retries; Retry re-runs that step only) andended(the course was deleted, or a run without a course discarded). The outline always waits forconfirm-outline({ outlineRevision, outlines?, commandId }) holding no worker, unless the run was started withoutlineReview: "auto". The course is created with the first scene, scenes are appended as they are narrated, and completion setsgenerationComplete. API (owner-scoped; another owner's run answers404):POST /api/generation-runs,GET /api/generation-runs?active=1,GET/DELETE /api/generation-runs/<id>,GET /api/generation-runs/<id>/events?after=<seq>(SSE with replay,resyncand heartbeat),POST …/confirm-outline,POST …/retryandGET /api/generation-runs/events(the owner's run changes). Limits:OPENMAIC_MAX_ACTIVE_RUNS_PER_OWNER(default 2),OPENMAIC_MAX_WAITING_RUNS_PER_OWNER(10),OPENMAIC_GENERATION_RUN_MAX_CONCURRENTruns per process (4) andOPENMAIC_GENERATION_RUN_STREAMS_PER_OWNER(16); finished runs are compacted afterOPENMAIC_GENERATION_RUN_RETENTION_HOURS(24). Outlines are capped at 100 scenes and 512 KiB, also in browser generation. #1756 #1759 -
Generation runs: a media lane generates the images and videos the outline asks for once the course exists, alongside the scenes and one item at a time, with a checkpoint per item: stored bytes commit with their checkpoint, a takeover places them instead of paying again, and a video takeover resumes the provider task it recorded. A slot that is off or unassigned skips its kind; a media failure never pauses the run and carries the route's code (
CONTENT_SENSITIVE,GENERATION_DISABLED,MISSING_MODEL,ASSET_QUOTA_EXCEEDED,MEDIA_PLACEMENT_FAILED,MEDIA_ELEMENT_REMOVED).POST …/retrywith{ commandId, media: { elementId } }runs one failed or skipped item again, also after completion, when its result is written into the current course without touching the author's other edits. The run snapshot includesmedia, andmediaevents report each item. Images found in uploaded materials are stored as assets of the course and can be placed on slides. #1761 -
Course materials are extracted when they are uploaded (#1754, E):
POST /api/materialsstarts the extraction in the background (?extract=falsedefers it), andGET /api/materials/<id>(and the owner's list,GET /api/materialswithoutsessionId) reports itsextraction:extracting,ready(withtextChars,pageCount,imageCountand what a course leaves out,truncated), orfailedwith the extractor'serror;POST /api/materials/<id>/extractionextracts a failed one again. A background extractor in every server process holds each extraction under a lease (a restart resumes it;OPENMAIC_MATERIAL_EXTRACTION_CONCURRENCY, default 2), lets owners take turns (OPENMAIC_MATERIAL_EXTRACTION_PER_OWNER, default 2 running per owner), and stops the extractor's requests and commands when the material is deleted or the extraction runs out of time. The same file uploaded again by the same owner reuses its finished extraction when it would be extracted the same way (same type and services). The stored result counts against the owner's byte quota (checked when it is published; over it fails withMATERIAL_QUOTA_EXCEEDED) and is capped (OPENMAIC_MATERIAL_EXTRACTION_MAX_RESULT_MB, default 100; larger fails withEXTRACTION_RESULT_TOO_LARGE). Uploads no run or agent session uses are deleted once unread forOPENMAIC_UNUSED_MATERIAL_TTL_HOURS(default 24); an open composer reads its materials back to keep them, and the same sweep removes result files no attempt published. A run reads the stored extraction, waits for one still running, and fails with a failed one's error (Retry extracts it again). The composer uploads a file as soon as it is attached and shows its progress, parsing (or transcribing), ready or failed with Retry on the material; Generate waits until every material is ready, and the preview then opens without a material analysis. What a course leaves out of a long material is shown on the material instead of in the preview.
Bug Fixes
- Pro workspace: the course list shows a placeholder for a classic generation from the moment it starts, matching the home page's card (title, state, and opening the generation preview), and turns it into the course in place once the course exists.
- Pro workspace: a course that is still being generated shows its progress in the course list and a "being generated" placeholder in the classroom pane instead of a blank pane; it cannot be opened or @-mentioned until it completes, then opens automatically, and the agent is told it is read-only until generation completes.
- Classic generation no longer stalls at the outline when the preview is closed: the short pause before an outline continues now runs on the server (
outlineReview: "countdown", generation-runs migration 4), so the run continues with no page open, while opening the review mid-stream or during the pause holds it (POST /api/generation-runs/<id>/hold-outline) for editing as before; with "Always review outlines before generation" on, the run waits for the learner's confirmation in any tab. - Home: the composer no longer keeps the last requirement as a draft; Generate creates the course card at once.
- Home: video thumbnails no longer leave failed requests in the browser's network panel on every load; each video is drawn from its poster, or from its opening frame (decoded once and cached) when it has none.
- Home: the course list shows as soon as the library loads, instead of waiting for every course's content and first-slide media; thumbnails load as cards come into view, a few at a time.
- Home: while the page loads, it shows its hero and a skeleton of the course library instead of a blank grey screen (in dark mode too), and the courses take the skeleton's place without moving.
- Home: course thumbnails are kept in the browser's local cache, so a reload shows unchanged courses at once without downloading their content and media again; a course that changed is read again, and Settings → Clear Local Cache clears them.
- Composer: the document extractor picker in the course-material popover is hidden when the deployment locks the
documentslot (lock: [document]orlock: all); uploading stays available. - Token Plan: connecting TokenDance also assigns the Pro agent (
agent) todeepseek-v4.1-flash; the default model stays the plan's own. - Classroom: the page no longer logs a
404for/api/persistence/runtime/sessions/whiteboard:…on every load and whiteboard open before anything was drawn; the learner whiteboard loads from the session listing. - Pro workspace: a course generated on the server no longer shows a "Read-only" badge to its owner; only a course saved from Discover, or one still being generated, is shown read-only.
- Settings: with every slot locked (
lock: all), Token Plan and Model Services are hidden, including a plan the workspace connected earlier and the Text-to-Speech tab for user voices; only the read-only course model map remains. - Generation preview: no "document parsing" step any more. Materials are extracted when they are attached, so the run's material step is not shown; a failure there still pauses the run with Retry.
- Composer: the course-material popover lists materials as compact rows on the popover's type scale, with file-type icons, a thin progress bar and icon buttons for Retry and Remove.
- Editor: slide elements can be dragged, resized and rotated in Safari; the Pro-mode editor no longer throws on mouse-down where the
TouchEventAPI is missing (desktop Safari). - Settings: Model Services scrolls its list to the selected service (by default the one in use, such as a connected token plan's provider further down the list), and the selected row no longer looks like the promoted first row, so the list always highlights the service the panel shows.
- Owner identity: a browser's first load establishes one anonymous owner. The page response now sets the
anonymous_idcookie (same value format and attributes as before) when the request carries no valid one and neitherOWNER_SINGLE_USERnorPERSISTENCE_SHARED_OWNER_IDis set, so the page's concurrent first API requests no longer each mint an owner and overwrite one another's cookie; a valid cookie is never replaced.POST /api/identity/legacy-import-bindingbinds only an owner the browser already presented (a request that minted its owner answers409 OWNER_NOT_ESTABLISHEDand the importer retries on a later load), so the legacy import can no longer be bound to an owner whose cookie a later response replaced and then stall with409 LEGACY_IMPORT_NOT_BOUND.OWNER_ANONYMOUS_PREMINT=falseturns page minting off (default on; a malformed value fails startup). Set it only when registered owner auth methods setanonymousFallback: false, and the server warns at startup when such a registration leaves it on; hosts that keep anonymous visitors keep it and skip it per request inmiddleware.tsfor requests their methods authenticate (see "Registering methods" in the README). - Owner identity: the anonymous identity lasts 400 days (the longest browsers keep a cookie) instead of 30, and is renewed while in use: every route handler and Server Action response that resolves to a valid anonymous cookie re-sends the same value with a fresh
Max-Age, so an active visitor no longer loses the whole library 30 days after the first visit. Page responses do not renew it, so pages stay cacheable, and a response that clears the cookie (a claim, a retired owner) never renews it. - Server persistence: the course library and folders work without the agent runtime.
/api/stages/**(list, create, read, save, delete, manifest, scenes, freshness, status, generation-complete, publish, unpublish) and/api/folders/**now gate onDATABASE_URLalone instead of also requiringOPENMAIC_AGENT_RUNTIME_ENABLED, so a deployment with server persistence and the runtime off no longer answers404there and shows an empty, unavailable library. Agent routes (/api/agent/**,/api/skills/**) and the session-scoped material reads (GET /api/materials,GET /api/materials/<id>) still require the runtime, while uploading to and deleting from the owner's material library (POST /api/materials,DELETE /api/materials/<id>) needs onlyDATABASE_URL, and without aDATABASE_URLeverything answers404as before.GET /api/agent/runtimealso reportspersistence. - Startup: a configuration refused by the boot validation in
instrumentation.ts(a malformedASSET_QUOTA_BYTES,ASSET_PENDING_TTL_MS,OWNER_WRITE_LOCK_WAIT_MSorOWNER_CLAIM_LOCK_WAIT_MS; anOWNER_CLAIM_TRIGGERother thanexplicit/auto; the removedOWNER_AUTHENTICATOR/TRUSTED_PROXY_*variables; a malformedPERSISTENCE_SHARED_OWNER_ID, one withoutACCESS_CODEor beside a registration that leaves outsharedTeamAuthMethod(), orsharedTeamAuthMethod()registered without it or not last;ASSET_S3_BUCKETbeside a registered asset byte store, orASSET_BYTE_EGRESS=redirectwith a byte store that does not declaresignsReadUrls) now exits the Node.js server with code1after one[boot] Invalid server configurationline carrying the original message. Any other boot failure (a module missing from the build, a host registration call that throws) also exits with code1, printed as[boot] Server startup failedwith its stack. Previously the server logged "Failed to prepare server", kept listening, and answered every request with500. Warnings never stop the server. - Server persistence: two concurrent creates of one new course id by the same owner no longer refuse the second as
reserved-document: creates of one id take turns, and the second saves as an update without running the create hooks again. - Server persistence: instances starting at the same time against one database no longer fail schema setup on a catalog race (
duplicate key value violates unique constraint "pg_class_relname_nsp_index"/pg_type_typname_nsp_index,tuple concurrently updated), which made an instance's first request answer500. Every schema bootstrap (documents,stage_metaand its ownership backfill, owner materials, assets, runtime, agent sessions, session materials, user skills, and the asset collector's) now runs under one PostgreSQL advisory lock held on a dedicated connection.
Security
- Server persistence: operations on one owner-bound document store no longer share mutable state, so concurrent calls on a store an agent run shares with its tools each gate their own stage;
create_stagealso runs sequentially within a tool batch. - Server persistence: runtime data of a deleted course reads as absent and takes no new writes, however the request path is spelled.
@openmaic/storage0.32.0:PgDocumentStoretakesassetReferencePrincipals(andAssetCollectora matching per-owner function) so a document write can no longer commit or pin another principal's asset entry; a store can refusecreateSessionwithRuntimeStageNotFoundError(404 STAGE_NOT_FOUND); and a session create over a taken id answers409 SESSION_ALREADY_EXISTSwhoever holds it, instead of403for another learner's session.