neuron-explorer
Qwen3.5‑4B 전용 조향형 뉴런 탐색기, 모든 MLP 뉴런을 밀어 결과를 보관·탐색
opticli는 개발자와 코딩 에이전트를 위해 만든 Optimizely CMS 12 사이트의 콘텐츠를 읽고 수정하는 명령줄 도구입니다
opticli is a command-line tool for reading and changing the content of an Optimizely CMS 12 site, built for developers and their coding agents.
개발 도구C# · JavaScriptGitHub @Epinova온라인운영자 미확인
써 보셨어요? 한 번만 눌러 주세요. 로그인은 필요 없어요.
개인 계정의 공개 저장소 소유자는 GitHub로 로그인해 운영자를 확인하고 소개를 직접 관리할 수 있습니다. 관리자 승인은 필요하지 않습니다.
로그인한 뒤 확인 파일을 만들어 이 저장소의 기본 브랜치에 30분 안에 커밋해 주세요. 다른 브랜치나 포크의 파일은 인정하지 않습니다. 파일을 올린 뒤 아래에서 확인하면 관리자 승인 없이 관리할 수 있습니다. 자격을 유지하려면 파일을 남겨 두세요.
로그인이 필요하면 로그인 화면으로 이동합니다. 로그인 후 이곳으로 돌아와 원하는 확인 버튼을 다시 눌러 주세요.
프로젝트 폴더에서 /nomorevibe verify를 실행하면 소유권을 확인하고 소개를 직접 관리할 수 있습니다.
1분 만에 확인하기Command line interface for for Optimizely CMS, made for agents
저장소 README에서 가져옴
README 전체를 불러오는 중입니다. 아래는 저장된 발췌입니다.
A command-line tool for reading and changing the content of an Optimizely CMS 12 site you develop on your own
machine. It is built for AI coding agents (Claude Code and similar) as much as for people. It answers the questions
you would otherwise answer with hand-written SQL or by clicking through the edit UI: what content exists, what a page
contains, which C# class and view render it, where a block is used, what is unpublished. It can also make changes
(set properties, add blocks to a ContentArea, create, translate, publish), as drafts by default.README 전문 보기 ↗A command-line tool for reading and changing the content of an Optimizely CMS 12 or 13 site you develop on your own machine. It is built for AI coding agents (Claude Code and similar) as much as for people. It answers the questions you would otherwise answer with hand-written SQL or by clicking through the edit UI: what content exists, what a page contains, which C# class and view render it, where a block is used, what is unpublished. It can also make changes (set properties, add blocks to a ContentArea, build a Visual Builder page, create, translate, publish), as drafts by default.
For editors, a separate package adds an MCP server to the site itself, so they can work on its content with their AI
assistant: OptiCli.Mcp for CMS 12, OptiCli.Mcp.Cms13 for CMS 13. See
MCP server for editors.
opticli is an independent open-source project. It is not affiliated with or endorsed by Optimizely.
reads: opticli ──fixed read-only SQL──▶ the site's CMS database
writes: opticli ──HTTP on 127.0.0.1 + per-run token──▶ site agent inside your running site ──IContentRepository──▶ database
opticli serve starts the site's existing build output with the site agent injected at startup: a
small assembly that adds a few HTTP endpoints under /_opticli/. The site's code and files are not changed. With
opticli ide on, the site you start from your IDE loads the agent instead, so changes show there without a restart
(see IDE mode).ref you can pass back to another command.In this README, "site agent" means that injected assembly. "Coding agent" means an AI assistant that runs opticli.
EPiServer.CMS.AspNetCore or EPiServer.CMS 12.x or 13.x) whose repository
you have checked out. What differs on CMS 13 is under CMS 13.Authentication=Active Directory Default), sign in with
az login first. The Azure CLI keeps one account per subscription, so signing in with a second account for the
same subscription replaces the first. To keep both, give the other one its own Azure CLI folder: export AZURE_CONFIG_DIR=~/.azure-other (in that shell; opticli, the site serve starts and az all follow it), then
az login. If the site serve starts can't sign in while opticli's reads work, its DefaultAzureCredential may be
trying another credential first: AZURE_TOKEN_CREDENTIALS=AzureCliCredential makes it use the Azure CLI's sign-in
(Azure.Identity 1.15 or newer in the site; 1.14 takes only dev, older versions ignore it). opticli doesn't set it.Development environment, with whatever it needs to start (its
own secrets and services).opticli is developed on Linux. Paths and process handling for Windows and macOS are covered, but get less testing.
Install opticli from NuGet as a global .NET tool:
dotnet tool install -g OptiCli
opticli --version
To update, run dotnet tool update -g OptiCli. CHANGELOG.md lists what changed in each release.
To install from source instead, build the package and install it from the output folder:
git clone https://github.com/epinova/opticli.git && cd opticli
dotnet pack src/OptiCli -c Release -o artifacts
dotnet tool install -g OptiCli --add-source ./artifacts
To update, run dotnet tool update -g OptiCli --add-source ./artifacts after a new pack.
To run from source without installing: dotnet run --project src/OptiCli -- <command>.
Run opticli from anywhere inside the site's repository. It walks up to the solution and picks the web project that
references EPiServer.CMS.AspNetCore, EPiServer.CMS or EPiServer.CMS.UI (or, for a site that lists the CMS's
packages one by one, any EPiServer.CMS.* package). It then finds that project's development database, as described
under Which database.
opticli doctor # what was found and where; does the database answer?
opticli types --kind page # page types and how many pages use each
opticli type ArticlePage # properties, C# class file, views
opticli resolve https://www.example.com/en/news/ # which content a URL shows
opticli get 123 --fields Heading,MainArea # one item, decoded
opticli where-used 456 # every page and block that references content 456
A change, end to end:
opticli serve # start the site with the site agent (background, 30-60 s)
opticli set 123 Heading="New title" --dry-run # validate; show before and after
opticli set 123 Heading="New title" # save a new draft version
opticli get 123 --version latest --fields Heading # check the draft
opticli serve --stop
When stdout is redirected, every command prints a single JSON line:
{"ok":true,"data":{"ref":"123","type":"ArticlePage","name":"News","status":"published","url":"/en/news/",
"properties":{"Heading":{"type":"String","value":"Hello"}}},"meta":{"source":"db","version":"0.23.1"}}
opticli ships a Claude Code skill that tells an agent when to use opticli and how to use it safely. skill/reference.md has the details it needs for writing content.
opticli skill install # into ~/.claude/skills/opticli/
opticli skill install --repo # into <repository>/.claude/skills/opticli/, to share it through git
For other agents, add the skill text to their instruction file, e.g.
opticli skill print >> AGENTS.md (and opticli skill print reference.md for the details). opticli doctor warns
when an installed skill is older than the opticli you run. Run skill install again after updating opticli. It
replaces a copy it installed itself, and asks for --force only when the files were edited since (or were installed
by an opticli older than 0.4, which kept no record of them).
The skill sets these rules for agents:
serve only when writing.opticli enforces its own safety rules either way (see Safety model).
opticli works with the project's development database: the one the site uses when you run it locally in
Development.
opticli reads the connection string (EPiServerDB unless --connection-name says otherwise) from the site's
Development configuration, in ASP.NET Core's order. The first source that has it wins:
ConnectionStrings__EPiServerDB (or ConnectionStrings:EPiServerDB) exported in the shell, shown as source
environment. A launch profile that sets the same variable replaces it when the site runs with that profile,
so profiles come first;appsettings.Development.json;appsettings.json.If that database is local, opticli uses it without asking.
User secrets are found by the project's UserSecretsId. opticli reads it, like TargetFramework and
AssemblyName, from the project file and the nearest Directory.Build.props (and the files that one imports).
The project file wins. $(Name) references to properties set earlier are expanded. Conditions are evaluated
when they are simple comparisons or Exists(...), with Configuration taken as Debug. A value opticli can't
expand is left as written (such a UserSecretsId reads no user secrets), a condition it can't evaluate is
skipped, and doctor warns about both.
Otherwise opticli asks once. That happens when:
appsettings.{Env}.json files have one.On a terminal it shows a numbered list. Anywhere else, commands fail with needs_selection (exit 6), and
error.details.choices lists the options, so a coding agent can ask the user and run opticli db use <id>.
The choice is saved in the user config file (see Files) and holds until that setting changes. If the server or database at that source changes, opticli asks again.
opticli db list # every connection string: id, name, server, database, local?, role, where it was found
opticli db use a71c3f # make it the development database (no id on a terminal: pick from a list)
opticli db forget # back to the default
opticli get 123 --db e02d9b # another database, for one run (a remote one is flagged in meta.warnings)
Other environments' databases can be added by name, and given a role, so production is only ever read: see Environments.
LocalDB. The CMS templates (epi-alloy-mvc, epi-cms-empty, CMS 12 and 13 alike) connect to SQL Server Express
LocalDB with AttachDbFilename=|DataDirectory|\<Name>.mdf, and the site points DataDirectory at its App_Data
folder. On Windows opticli resolves |DataDirectory| to the project's App_Data the same way; the site started by
serve keeps its own value. LocalDB counts as local. Linux and macOS have no LocalDB, so neither the site nor opticli
can use that string there: opticli refuses it (refused, exit 3) with a hint to point the connection string at a SQL
Server database by name, e.g. SQL Server in a container (doctor shows which file it comes from).
When the database in use is remote, or has a role other than local, every response carries meta.database
(server, name, local, development, role). A remote database that isn't the development one also adds a
warning, and so does a production database.
serve runs the site only against a local database or the chosen development database. Any other remote
database is refused (exit 3), also one passed with --connection or OPTICLI_DB, and such a string can't be chosen:
only one in the site's configuration can. To run the site against it, add it to a launch profile in
Properties/launchSettings.json (environmentVariables, ConnectionStrings__EPiServerDB; mind the password in a
committed file), user secrets or appsettings.Development.json, then choose it from opticli db list with
opticli db use <id>. serve then runs against it without --connection.
serve sets ConnectionStrings__<Name> to the database it runs against and removes other spellings of it inherited
from the shell, so an exported one can't win over the pin. It turns the site's scheduler off for every run (see
Scheduled jobs), and against a remote one it also turns off, for that run:
So the CMS doesn't change a shared database just because a local build starts. serve also checks, before it
starts the site:
__EFMigrationsHistory lacks would be applied
by a site that migrates at startup, so serve refuses (exit 3) unless --allow-pending-migrations.serve says which side to update. EPiServer.Framework 12.17 and later accept a schema one version newer; earlier
ones (and a version opticli can't read) are taken not to.Once the site answers, serve reports drift: what differs between the build and the database (see
Shared databases).
opticli knows which databases are production, and keeps them read-only and never the default. Add the databases of other environments by name, with a role:
opticli db add prod --role prod # asks for the connection string, and its password without showing it
opticli db add test --role test --connection "Server=...;Database=...;User Id=...;Password=..."
opticli db role e02d9b prod # give a database the project already has (from db list) a role
opticli db remove test # forget the entry; its role stays
opticli get /en/about/ --db prod # read production for one run
A database added by name is listed by db list and used only when a command asks for it with --db <name>. The
connection string is kept in the user config (see Files), with its password, so save only a read-only
login for production (db_datareader): opticli doctor warns when a production entry's login can write (it checks
db_owner, db_datawriter and INSERT/UPDATE on tblContent, and says nothing when it may not ask or can't connect).
Run db add at your own terminal rather than having an agent pass --connection, which puts the password in the
command line.
The roles are local, dev, test and prod. A database on this machine is local until it is given another; a
remote one has none until it is given one, and behaves as remote databases always have (dev and test do too:
shared mode and the drift gate, see Shared databases). A role belongs to the server and
database, not to an entry: once a database is prod, every entry, setting and connection string that points at it
is production, in every project. The server is compared as SqlClient reads it, ignoring case, a tcp: prefix, the
default port 1433, leading zeros in a port, an instance name beside a port (SqlClient ignores it), the default
instance's name MSSQLSERVER and the loopback names (localhost, 127.0.0.1, ., (local), ::1 are one
server); another name for the same server (an IP address, an alias) is another server. A connection string whose
Failover Partner is a production database is production too. Giving a database a weaker role than it has
(prod to test, say, by db role or by a second db add) needs a terminal, where you confirm by typing the
database's name; elsewhere it is refused (exit 3, details.reason: "roleLowered"), so an agent can't make
production writable. db remove keeps the role. A local database may be given prod; the tests use that.
What prod means:
db use refuses it (exit 3), and a saved choice, a configured connection or
a Development default that turns out to be production is refused with a hint to choose another: a command without
--db never reaches it.--db, --connection, --profile, OPTICLI_DB). Each response has a warning in
meta.warnings and meta.database.role: "prod", and opticli connects with ApplicationIntent=ReadOnly unless the
connection string sets the intent itself. sql works too (it is read-only and keeps personal data out).serve, env and every write command (including --dry-run and apply) are refused (exit 3,
details.reason: "prodDatabase"), whichever way the database was picked. The one way to write to production is a
sealed apply, which only the user runs, in their own terminal: see Writing to production.opticli export writes content of any database as a plan folder that apply --update-existing brings into another
database, with the same GUIDs. It only reads the database (over SQL, never through a site), so it works on production:
opticli export /en/about-us/ /globalassets/about-us/ --descendants --db prod --blobs ../prod-blobs --out about-us
opticli apply about-us/plan.json --update-existing --dry-run # against the development database; then without --dry-run
--descendants everything below them; --site <name> a site's start page with
everything below it, the site's assets and the global assets; --all every site. Blocks, media and folders in a "For
this page" folder go with the content they belong to (a folder there is made with create --for), and what is in such
a folder with that folder. Never the root, the recycle bin, the asset roots themselves or content in the recycle bin.
Every language branch, or --languages en,sv (an item's master branch always).--out, default export-<database>-<time>; an earlier export's folder is written over, any other
that isn't empty is refused, also one with a plan of its own): plan.json, files/ with the media files, and texts/ with rich text
longer than 4000 characters, which the plan reads as @file values.create, block or upload step with the source's GUID and its values, including the
built-in settings (publish dates, URL segment, menu visibility, simple address, shortcut, sort order and index,
category); parents come first, and content before what refers to it where references allow it. References to what
the plan creates are $ids, to anything else GUIDs (never ids, which differ between databases), also inside local
and inline blocks, block lists, rich text and CMS 13 compositions (which keep their node keys, so a second run finds
them). Values that refer to content created later in the plan (a cycle: A links to B and B to A) are written by a
set once both exist. Other language branches are translate steps with their culture-specific values. A branch
published in the source is published by the last step that writes it; an unpublished one stays a draft, and one
scheduled for publishing gets a publish step with its publishAt (a time that has passed leaves it a draft, as the
source has it); a newer draft than the published version is left out. A top-level "source" says where and when the
plan was read, and the version written of each branch; apply doesn't read it.--blobs, a folder holding the blob containers (a copy of the site's
App_Data/blobs) or an Azure blob container's https:// URL with a SAS token (read with a plain GET; the token is
never shown), or, for a local database, from the project's file blob folder (EPiServer:Cms:FileBlobProvider:Path,
also from the environment). Media whose file isn't found is left out and listed; leaves all media out.
Applied again, an upload whose file changed gets the new file.opticli sync pull keeps the local database's content in step with another environment's. It is an export of the
source (read over SQL only, so production is fine), a diff against the local database by GUID, and an apply --update-existing of only what changed, through the local site:
opticli db add prod --role prod # once, at your terminal: a read-only login
opticli serve # the local site, on the current build
opticli sync pull --from prod --all --blobs ../prod-blobs --dry-run
opticli sync pull --from prod --all --blobs ../prod-blobs --delete
opticli sync pull --from prod /en/news/ # a ref (or several) and everything below it; --site <name> for one site
drafts); what the source moved is moved (a move, then the update); what is in the local recycle bin and live in
the source is restored. With --delete, content synced before that the source no longer has, or has in its recycle
bin, is moved to the local recycle bin (never deleted for good; content the source has in its recycle bin counts as
synced before even on the first pull, since its GUID says it came from there); without it, it is listed in gone.
Content only in the local database, never synced, is left alone, and so are language branches only it has.updated in its --dry-run first. The
same goes for a pull without its state (another project folder, another user config, a deleted state file). A local
change since the last pull (a branch saved, a language branch made locally that the source has too, the item moved
or moved to the recycle bin) to content the source changed too is a conflict:
listed in conflicts and left as it is; --overwrite-local writes the source's content over it (the local versions
stay in the history, and other people's drafts are included). Content changed only locally is listed in kept. Before
the baseline exists, a local draft newer than the published version counts as a local change. A local trash of
synced content is a conflict too; --overwrite-local restores it.How long it takes, on a local copy of the edge-case site with 16 000 pages and blocks: the first pull into a database that has none of them about 6 minutes (a minute to read, compare and check the plan, then about 20 ms a step through the site); the baseline of a copy that has them all, 20 seconds; a pull after 50 edits in the source, 7 seconds; one with nothing changed, 3–5 seconds (it compares versions only). A site whose saves run event handlers (search indexing, cache warmers) writes slower.
Content gets to production in one command that the user runs in their own terminal. A coding agent prepares it and checks it, and seals it; it can't run it, and opticli doesn't let it:
opticli sync pull --from prod --all # first: the local database is a recent pull of production
opticli sync push --to prod /en/campaign/ # what changed locally, as a plan, sealed; or:
opticli apply campaign.plan.json --dry-run --seal-for prod --update-existing
# both print the command for the user (data.userCommand):
opticli apply /path/to/plan.json --db prod --seal 7f3a9c1e0b7d4e55 # the user, at their terminal
Sealing (apply <plan> --dry-run --seal-for <name>, an agent may do it) checks the plan against the local site (a
dry run, as apply --dry-run does; the development database must be local, with its site running on this build),
reads production over SQL with its saved read-only login, and writes nothing there:
$id of
what it creates): delete, unpublish, access, discard, a translation's remove, includeDraft, force and
version ids are refused (refused, exit 3, details.reason: "sealedPlan", every problem listed), and so are content ids
and URLs (in refs, a ContentArea item's ref, the value of a reference property), which name other content in another
database. A create or upload needs a fixed GUID (guid, or guidNamespace), so a second run can't make a copy. An
upload (or replace) is only sealed for a site whose build has the CMS's Azure blob provider (EPiServer.Azure, on
CMS 13 EPiServer.Blobs.Azure): the run writes files only to production's blob storage, which the user gives it;sealedPlan). The seal records them by name
(leftovers), the run shows them before you type anything, and the run's site may find those and nothing else.
Any other difference (what only the build has, a property of another type, stores, migrations) is a conflict
(exit 5, details.reason: "modelDiffers");appsettings*.json
and user secrets), production's model fingerprint, and the newest and published version (with when it was saved) of
every item the plan touches, and whether the items it only names are there. It is seals/<id>.json in the user config
folder, with a MAC (HMAC-SHA256, keyed with seal.key there), and is good for 24 hours, once.sync push --to <name> (<ref>... | --site <name> | --all) builds such a plan from the local database and seals it: an
export of the local content, compared with production by GUID, with only what differs (created, updated, moved, or
restored from production's recycle bin). It leaves out media (listed, unless production has it as it is here: in the same
place, with the same values and stored file; upload or change it in production's edit UI), unless --media is given: then
media production lacks, or has with another file or other values, is uploaded with its file from the local site's blob
folder, and the run asks for production's blob storage. It also leaves out content
production already has as it is, content with someone's unpublished changes in production, and content production
changed since the last pull, or that was never pulled from it (conflicts: the pull's sync state says what production had
then; pull first). It never deletes. The plan folder (--out, default push-<database>-<time>) must stay until the
command has run.
The run (apply <plan> --db <name> --seal <id>, the user only) refuses without a terminal on stdin, stdout and stderr
(refused, exit 3, details.reason: "sealNeedsTerminal", with the command in details.userCommand), takes no other
option (the seal's are used), and:
--db names (production, the same server and database),
the project, the plan file and its hash (read once: the plan runs from those bytes, so a file swapped afterwards isn't
what runs), the build, and its age. Another target, project or plan file is refused; an expired seal, a changed plan
or build is a conflict (exit 5): seal again;conflict, exit 5,
details.reason: "productionChanged", details.changes says what changed: seal again);127.0.0.1 only (a site that also listens elsewhere is
stopped before anything is written), with no serve state (no other command finds it), the token and the connection
string handed to it on stdin rather than in its environment. It serves opticli's agent and nothing else: the site's
pages, edit UI, login and other endpoints answer 404 while it runs, so no other program on the machine reaches the
site while it runs in Development with the write login;--include-draft).The result says whether production still needs a restart (restartNeeded; see the event bus below), and lists every
uploaded file as found in production's blob storage (media).
Production's event bus and blob storage. A site on Azure (DXP, or its own Azure setup) usually registers the CMS's
Azure event and blob providers only outside Development, so the run's site, which runs in Development, would neither
tell production's servers about its changes nor write files where production keeps them. When the build has the providers
(EPiServer.Azure on CMS 12; EPiServer.Events.Azure / EPiServer.Blobs.Azure on CMS 13), the run asks for them at the
terminal, like the write login: for this run only, never written to disk, logs, output or the seal, and never from an
option, environment variable or configuration entry. opticli checks Service Bus and Blob Storage with the Azure SDKs
(Azure.Messaging.ServiceBus, Azure.Storage.Blobs), which ship with the CLI only; the site agent brings no Azure package
into the site: it registers the site's own CMS Azure providers, with the values typed winning
over anything in the site's configuration (its Key Vault may hold another environment's). A site without them is asked
nothing.
EPiServer:Cms:AzureEventProvider:TopicName in the project's
appsettings*.json when they agree, else the CMS's default MySiteEvents). Before the seal is used, the topic must exist
and have subscriptions (one per server of production; none means the wrong topic or bus); their names (the servers'
machine names) are shown, so check they are production's. Once the site runs, its event
provider must be the Azure one with that connection and topic, and right before the first save its own subscription must
be on the topic (otherwise nothing is written). After the run, the site sends what its provider still has queued
(the CMS would drop it when the site stops), once the site has raised no cache removal for a few seconds (a save's own
publish handlers keep raising them for a while), and a probe subscription counts the run's messages on the topic. Only
then, and when the site's log has no "Failed to send event" before the host began to stop, is restartNeeded false
(removals the site raises while it stops are listed apart, events.notSentWhileStopping) (events.sent says how many); anything
uncertain leaves it true, with the restart warning and why (events.problem). Enter: no event bus, and the restart warning as before. A bus the site can't reach makes it fail to start (nothing
written; the seal is used).EPiServer:Cms:AzureBlobProvider:ContainerName, else mysitemedia).
The container must exist before the seal is used; Enter refuses the uploads (sealedPlan) with the seal unused. The site
must use the Azure provider with that connection and container as its default before anything is written; uploads send
the bytes the run read and hashed, never the file again. After the run every uploaded file is looked up where
production's database now points (<content GUID>/<file> in the container); one that isn't there with its size, or
can't be checked (problem), is a conflict (exit 5, details.reason: "blobMissing") that still carries the run.
A run that stops part way checks the files it saved too, and its error's details have events, media and
restartNeeded beside what was saved.What a seal doesn't cover:
plan.json and the files beside it) before you type the
name. A plan can change any value of the content it touches, its publish dates included: an expiry (StopPublish) that
has passed takes the content offline once it is published (the summary counts those steps).restartNeeded).Development, with your
local settings (search index and webhook endpoints included). The seal makes sure the build is the one that was sealed;
it can't say what that code does. Review what the agent changed in the code before you run a sealed apply.conflict, details.reason: "driftNotSealed") if its site finds any other difference, or a leftover the seal
didn't list.Why an agent can't: the run needs a person at a terminal and the write login, which only the user has (save only a
read-only login for production, see Environments). The seal ties what the user sees to what runs. An agent
can still fake a terminal (script, expect); that gets it past the terminal check but not the write login. With
integrated security or Microsoft Entra ID, the agent runs as the user and could write to production with any tool, sealed
apply or not: give production a read-only SQL login for reads and a separate login for writes. See also
Things these rules can't see.
| Rule | Enforced by |
|---|---|
A remote database is used only when the user chose it as the development database, or for one run with --db or --connection, with a warning on every response. Only literal loopback names (localhost, 127.0.0.1, ::1, ., (local)) and LocalDB count as local. A host name that resolves to 127.0.0.1 counts as remote. | CLI, before connecting |
export only reads (over SQL, never through a site), so it works on production; it writes nothing but its own folder. | CLI |
sync pull only reads its source (over SQL, read-only intent for production) and writes only to a database with the role local (refused otherwise, exit 3, details.reason: "syncTarget"), through its site, as apply --update-existing does. It never deletes for good: --delete moves content to the recycle bin. A local change to content the source changed too is a conflict, left alone unless --overwrite-local. | CLI, then the site agent |
A database with the role prod is only read, and only when a run picks it: it is never the development database (db use and a saved choice that became production are refused), and serve, env and every write are refused (exit 3, details.reason: "prodDatabase"), whether it was picked by name, id, database name, --connection, --profile, OPTICLI_DB or an exported connection string. Roles belong to a server and database, and a weaker one needs a person at a terminal. Reads connect with ApplicationIntent=ReadOnly. The only exception is a sealed apply (next row). | CLI before anything is read, again on the way to the site, and when starting it |
| A sealed apply writes to production only at a terminal (stdin, stdout and stderr), after the user typed the database's name and the write login, once per seal (24 hours), and only if the plan and its files, the build, production's content model and every item the plan touches are as sealed (checked before the login is asked, and again right before the first save). Sealed plans create, change, move, translate, publish and upload by GUID; never delete, unpublish, change access, discard, or include someone else's draft. Files go only to production's blob storage, and the run joins production's event bus only, when the user types them at the run's terminal (never an option, variable or setting); what the site runs with is checked before anything is written. The site for the run listens on loopback only, serves nothing but the agent (its own pages and edit UI answer 404), gets its token and the write login on stdin (not in its environment or a state file), runs in shared mode, accepts no drift but the sealed leftovers (types and properties only in production's database, by name), and is stopped afterwards. The write login is never saved. |
Things these rules can't see:
localhost,<port> to a remote server looks local to opticli.XDG_CONFIG_HOME, APPDATA): the user config is what holds the roles.script, expect) passes
that check. So does a sealed apply's; it then needs the write login, which an agent doesn't have, unless production
signs in with the user's own identity (integrated security, Microsoft Entra ID).ptrace_scope, which Ubuntu sets), makes one of them
dump core where you can read the dump (a raised core limit, or systemd-coredump's coredumpctl), or opens the site's
stdin in its first moment, can read it. If opticli is killed hard (SIGKILL), the site it started for the run keeps
running until stopped by hand; the run prints its pid.Don't point opticli at such a connection unless you mean to write to what is behind it.
A remote development database is often shared: with other developers, and with the environment it belongs to. The database doesn't say which build or commit is deployed against it. So opticli checks whether the database matches what the local build expects, which is what matters before a write:
Each difference says which side is ahead. local: your branch has changes that aren't deployed there (check out what
is deployed, or deploy first). database: the database has what your build lacks. Either the environment runs newer
code than your checkout (pull and build), or a class or property was removed from code and the CMS kept its content
type or property for the content that uses it (opticli types --orphaned lists such types); pulling doesn't change
those. When only EF Core migrations or the CMS schema version are on the database's side, that is newer code (pull and
build). unknown: they differ, and the database doesn't say which side changed. Required, display names, sort order,
tabs and [AllowedTypes] aren't stored for a model: the running code decides them, so they never show as drift. On
CMS 13, content types of unknown origin (see CMS 13) are listed with informational: true and don't stop
writes.
For a content type or property only in the database, opticli looks in your checkout's git history for the commit that
removed it from code, and names it on the item in opticli drift, doctor, serve's start message, the drift
error and the prompt (removedFromCode: commit, date, subject; in text "its class was removed from code in abc1234
(2026-09-14)"). A type is looked up by its GUID (the [ContentType(GUID = ...)] string), then by class <Name> (not
in a comment), in *.cs files; by name only when the removed class had no GUID or the type's. A property is looked up by its declaration
(not a comment) in its type's class files, then in those of the base classes they name, as far as the repository has
them; a same-named property of another class doesn't count. Only what isn't in HEAD counts, and when opticli can't
tie a removal to the type it names none. When every difference on the database's side is such a removal, the advice
says so instead of "pull and build": the CMS kept them for their content, or that environment doesn't run the removal
yet. The lookup runs only below a .git (in the project's folder or above) with git on the path, for up to 50 items
within 5 seconds; without git, or when it takes longer, drift is reported without it. It is the CLI's addition: the
fingerprint and what stops writes stay the site agent's.
opticli drift lists the differences and doctor shows them. Once serve has reported drift, a short warning comes
with every command that uses the database, and with serve --status. A site you start yourself with opticli env
reports drift only through opticli drift and the write errors. While there are any differences, every write stops, not only those to a type that differs: the risk is the build as a whole (its event
handlers and validators), not only its models. Reads keep working, since they don't run the site's code. Restart
serve to compare again after a deploy or a pull.
What opticli can't turn off is the site's own startup code. Against a shared database, a local build still:
Database.Migrate() if the site calls it at startup (the reason for the migration check);IContentRootService), and Dynamic Data Store
stores when they are first used, if they don't exist yet;So use a SQL login without DDL rights (no db_ddladmin or db_owner) for a shared database where you can. Schema
changes then fail instead of reaching everyone. Some add-ons create or alter their own tables, views or stored
procedures every time the site starts, and a site with those may not start with such a login. Check what your
add-ons need first.
opticli <command> --help lists every option with an example, and a bare opticli shows an overview.
| Command | Answers |
|---|---|
doctor | project, connection string candidates, database, schema version, site agent, drift (against a shared database), sites whose primary host differs from the saved sites primary mapping, installed skill |
db list, db use, db forget | the development database (see Which database) |
db add <name> --role R, db remove <name>, db role <id|name> R | other environments' databases by name, and roles: prod is only read (see Environments) |
sites, languages | site definitions (CMS 13: applications) and hosts; language branches |
types [--kind] [--unused] [--orphaned] [--sort] | content types with instance counts and kinds (page, block, media, folder, other; on CMS 13 also experience, section, element, contract); --orphaned: types whose class is gone from the code, which the CMS keeps while content uses them (the running site checks every class, meta.source: agent; otherwise the site's sources are scanned for its own types, with a warning saying why); see Orphaned content types for removing them |
type <name> | properties (type, culture-specific, required, tab, order, source line, [AllowedTypes]; one that isn't in the code with how many values it has stored), C# class file, views |
allowed-in <type> | which ContentArea/reference properties accept a type, from [AllowedTypes] in code |
get <ref> [--lang] [--version] [--fields] [--expand] [--variation] | one item, typed and decoded (ContentAreas, local blocks, rich-text links; on CMS 13 a Visual Builder composition, and --variation for a content variation) |
tree, children, |
A <ref> is a content id (123), a version (123_456), a content GUID, or a URL or path (/en/about/,
https://host/en/about/). Content from a content provider, such as images from a DAM, shows up as 63__provider. You
can pass that form back in property values, ContentArea items and links. The id before __ is local to one database;
the GUID is the same in every environment. --lang <code> picks the language branch. The default is the master
language, or the language the URL selects. On CMS 13, Visual Builder blueprints are left out of tree, children,
find, search and drafts unless --blueprints.
opticli serve)These commands write through the running site: the one opticli serve started, or the one you run from your IDE
with IDE mode on.
set, create, area (add, remove or move ContentArea items, shared blocks or, on CMS 12.20+, inline blocks
stored in the area), block create, upload and translate save a draft unless --publish is given. upload <file> adds a PDF, image or other file (up to 50 MB) as media, as the
type the site maps its extension to, and prints where the file was stored. create doesn't take media types (that
would be media without a file).publish, unpublish (takes a published branch offline, as the edit UI's expiry does), discard (deletes one
unpublished version; it can't be undone), move and delete (to the recycle bin). delete stops when other content
references what it deletes, unless --ignore-references.restore <ref> brings deleted content back out of the recycle bin, as the edit UI's Restore does: below the parent
it had before it was deleted, which the CMS stores for every move (trash shows it as originalParent), or below
--to <parent>. It keeps its versions, so what was published is live again. Name it by id or GUID: content in the
recycle bin has no URL. Something below deleted content can't be
restored on its own (restore what was deleted), and a parent that is in the recycle bin too is a conflict.create, block create, upload and move put content only where it can go: pages below pages, blocks, media and
folders in asset folders, and only where the parent type allows the type ([AvailableContentTypes] and admin mode's
settings, as the CMS answers it).access <ref> with --grant Role=Levels, --user Name=Levels, --revoke Name, --break-inheritance or
--inherit changes one item's access rights. Children that inherit follow; nothing is applied to descendants.
Access rights aren't versioned, so the output shows them before and after. The root, the recycle bin, start pages
and asset roots are refused, and so is a change that leaves no role with Administer. A "For this page" folder and
the content in it have the access rights of the content that owns the folder (assetsOf), so they are refused as
well, as in the edit UI: change the owner instead.composition (CMS 13) adds, moves, changes and removes the sections, rows, columns and elements of a Visual Builder
experience; set and create also take a whole composition, makes content from a blueprint,
and writes a content variation. See .Every write command takes --dry-run. Structured values use --values, e.g.
--values '{"MainArea":[{"ref":"456"}]}'; a value may also be given as get shows it ({"type": ..., "value": ...};
a reference in it is written by its GUID, so get output from another database names the same content).
set and area check that nobody saved a newer version in the meantime (exit 5 on a conflict). --base-version <id> pins the version the change is based on, and --force skips the check.
--from published (or --from <version>) bases the change on that version instead of the latest, and the check
stays: newer drafts are left out and stay as they are, and the output lists them (leftOut).
A publish puts the whole version live. When someone else saved unpublished changes in it, opticli shows them and asks on
a terminal; elsewhere it fails with conflict and details.reason: "pendingDraft", and --include-draft confirms.
set ... --from published --publish publishes a change without them.
Output after a publish names previouslyPublished, the version to publish again to go back. Content with an approval
sequence isn't published directly (exit 3); --request-approval sends it for review instead, as the edit UI does.
Against a shared database that differs from the build, writes stop with drift (exit 5) until --accept-drift <fingerprint> (apply --accept-drift for a whole plan); dry runs don't stop.
skill/reference.md documents value syntax and the plan format.
A restored copy of a production database has the production host names. Locally, every site but the one with the *
host is unreachable, and absolute URLs (canonical links, sitemaps, structured data, emails) point at production.
sites primary points each site at the port the site listens on, through the running site (opticli serve):
opticli sites primary "Site A=localhost:5001" "Site B=localhost:5002" "Site C=localhost:5003" --dry-run
opticli sites primary "Site A=localhost:5001" "Site B=localhost:5002" "Site C=localhost:5003" --save
For each pair the site gets the host if it doesn't have it, as its primary host. The previous primary host and the
site's Edit host become undefined: an Edit host on a production name sends the edit UI and preview there, and
without one the CMS edits on any host. The site's URL (SiteUrl) follows the new primary host, https:// unless
--https false, keeping SiteUrl's path. The CMS builds a host's links on SiteUrl's scheme when the host has no https
setting of its own, and adds SiteUrl's host back to the site whenever it is saved. The production hosts stay, so
opticli resolve https://site-b.example/... still finds content.
A pair without @lang replaces the site's primary host: the one for every language, or, on a site whose only primary
host is bound to a language (a site whose only hosts are for nb, say), that one, and the new host is for nb too;
the changes line says so. With primary hosts for several languages only (or one in a language that isn't enabled),
the new host is for every language beside them, and meta.warnings gives the pairs for the rest. A host that is a
primary host already keeps its language, and the language is worked out on the site as it was before the command, so
the order of the pairs doesn't matter and running them again changes nothing. SiteUrl follows a pair without
@lang, and a @lang pair whose primary host SiteUrl was on. A pair without @lang whose host is already one
language's primary host, beside a primary host for every language, is that language's pair (Site A@sv=...): SiteUrl
stays, and meta.warnings says so. Every primary pair, with or without @lang, makes the Edit host undefined (unless
--keep-edit): the CMS has one Edit host per site, for every language. doctor and --from-config read a saved
entry without a language the same way.
Putting a site back: the commands only ever put SiteUrl on a primary host. Once it has moved, a SiteUrl that was on a host that isn't primary (as on a site the CMS created on its first request) can't be put back with them alone.
A host is name[:port]. The default port of the host's scheme is left out, as browsers leave it out of the requests'
host: localhost:443 is the host localhost with https, localhost:80 with http, while http://localhost:443 (or
localhost:443 --https false) keeps its port. A host a site already has under another spelling
(www.site.example:443, from admin mode) is found by the name opticli sites shows. IPv6 addresses can't be site
hosts in the CMS; use localhost.
| Option | Does |
|---|---|
"Site A@nb=localhost:5004" | the primary host for one language (an enabled one); SiteUrl stays. Without it, the host is for every language, as most are. meta.warnings names the languages whose URLs still use a production host. |
https://localhost:5001/ | the same host; the scheme sets its https setting. --https true|false|unset overrides it. |
--keep-edit, --keep-site-url | leave the Edit host, or SiteUrl, as they are |
--dry-run | validates through the site and shows each site's end state, saving nothing |
--save | after a real run, saves the pairs (with --keep-edit and --keep-site-url) in the opticli user config, replacing that project's entries for those sites. Entries are keyed Site A and Site A@nb, so with sites named both Shop and Shop@en, saving for Shop replaces the Shop@en entry too. |
--from-config | runs the saved pairs: after the next restore, opticli sites primary --from-config is all it takes. A saved site the database doesn't have is skipped with a warning. |
--forget <site> | drops a site's entries (or one entry, "Site A@nb") from the saved mapping, e.g. for a site that was deleted or renamed |
All pairs are one batch: every pair is checked, and the end state of every site as the CMS would save it, before
any site is saved. Then nothing is saved if one fails, and the error names each failing pair. Should the CMS still
refuse a site while saving (or the database fail), the sites saved before it stay saved, and the message says
Already saved: ...; not saved: .... A site that is already as asked is unchanged and isn't saved, so the command
is safe to run again, also after such a failure. doctor compares the saved mapping with the database and says when
to run --from-config.
| Problem | Error |
|---|---|
a host another site has (in any spelling: localhost:443 is localhost), two primary hosts for a site and language, * on two sites, host add --lang with a language that isn't enabled | validation, exit 5 |
a host with a path, query, other scheme or a bad port, a pair without = | usage, exit 1 (read by the CLI) |
a site that doesn't exist, also "Site A@xx=..." when xx isn't an enabled language (@xx is then read as part of the name; the hint says so) | not_found, exit 2 |
| adding a host the site has | conflict, exit 5 |
removing the site's last host; anything but host add --type undefined against a shared database | refused, exit 3 |
sites host add <site> <host> adds one host (--type undefined|primary|edit|redirect-permanent|redirect-temporary,
--lang, --https); a host the site has is a conflict. sites host remove <site> <host> removes one, but never the
site's last host or SiteUrl's host. Both take --dry-run.
On CMS 13 the sites are applications, and a few of these rules differ: see Sites are applications.
Saving through the site clears its site definition cache, so the site serve runs uses the new hosts at once. Another
process running the site against the same database (your IDE's) keeps the old ones until it restarts, unless remote
events are set up, or unless it is the site that saved (IDE mode). Site
definitions aren't versioned: the output lists every change and the hosts after it.
A restored copy of a production database has its jobs enabled, with next runs in the past. A site started against it
would start every overdue job at once: imports and syncs reach external systems, emails go out, and the built-in
Automatic Emptying of Trash deletes the recycle bin for good. So serve turns the site's scheduler off for its run
([opticli] Scheduler is off for this run in the site's output, scheduler: off in serve --status and doctor).
The site still registers its jobs at startup, and jobs run still runs one. serve --scheduler (or "scheduler": true
in the user config) leaves the scheduler as the site sets it, and then warns about the jobs that are overdue;
doctor counts them too. opticli env leaves it as the site sets it: a site you start yourself is yours.
opticli jobs # every job: schedule, next run, overdue, last run and how it ended
opticli jobs log --failed --since 1d # what failed since yesterday, with the jobs' messages
opticli jobs run "Publish Delayed Content Versions" # run it now (needs serve) and wait for it
opticli jobs set "Content import" --every 1h --next now --dry-run
Reading (jobs, jobs log) comes from the database (tblScheduledItem, tblScheduledItemLog) and needs nothing
running. A job in the site's code (a [ScheduledPlugIn] or, on CMS 13, [ScheduledJob] class) has its file and line
as source, and one the database doesn't have yet is listed with registered: false: the site registers jobs when it
starts. running: "stale" means the job is marked as running but the process that ran it stopped pinging. A run's
finished is when the CMS logged it, and started that less its duration.
A <job> is its id (GUID), its name, its class (EPiServer.Util.BlobCleanupJob or BlobCleanupJob), or a part of its
name only it has. Several matches are a usage error that lists them.
| Command | Does |
|---|---|
jobs run <job> | runs it now through the site, as the admin UI's "Start manually" does, also while the scheduler is off, as the user opticli; waits for it by reading the job tables, shows its status messages on a terminal, and ends with its status, duration and message. Exit 0 when it succeeded, 7 (job_failed) when it failed, couldn't start, or was stopped or aborted. --no-wait returns once it has started, --timeout <s> stops waiting (exit 4, timeout) and Ctrl+C too: the job goes on running. Already running: conflict. Jobs other than the CMS's own (the site's, and add-ons' such as Commerce or Search & Navigation) run without asking, with a warning in meta.warnings (in error.details.warnings when the run fails): opticli doesn't know what they change or contact. A run of an overdue job moves its next run on, as in the admin UI. |
jobs run <job> --allow-destructive | needed for the jobs that delete for good: the CMS's Automatic Emptying of Trash, Remove Abandoned BLOBs, Trim Content Versions, Remove Unrelated Content Assets, Change Log Auto Truncate, Notification Message Truncate, Monitored Tasks Auto Truncate and (CMS 13) Remove Unused Content Variations, and Commerce's Remove Expired Carts, Permanently Delete Archived and Remove Expired Lowest Price; and for Archive Function, which moves every expired page to its archive page (each move can be undone by hand). Matched by class name. Without it they are refused (exit 3), naming what they do; nothing implies it. |
jobs stop <job> | asks the job the site runs to stop, as the admin UI's Stop does (the job's own code decides when), and waits up to 30 s for its run to end: normally cancelled. A job whose class can't be stopped is refused; one that isn't running is a conflict. |
jobs set <job> --enabled true|false --every 30m|1h|1d|1w|1mo|1y|manual --next now|<time> | changes its schedule through the CMS's job repository, as the admin UI's form does; the output has it before and after (schedules aren't versioned). --every on a job without a next run needs --next; manual clears the next run. Units: m minutes, h, d, w, mo months, y, in any case except M, which is refused as ambiguous; intervals under a minute are refused. A next run in the past makes it overdue; with the scheduler off nothing starts it there. A change that lets the scheduler run one of the jobs (enabled with a next run, an earlier next run or a shorter interval) needs too; disabling one or making it manual never does. |
run, stop and set take --dry-run. Against a shared database they are refused (exit 3), with no override: the
deployed site's scheduler uses the same jobs, and a job writes to the content everyone shares. jobs and jobs log
work there.
A restored copy of a production database has production's users: often accounts from another identity provider, or
local ones whose passwords nobody knows here. users add makes a login to sign in with locally, through the site's own
ASP.NET Identity (services.AddCmsAspNetIdentity<...>(), the user class the CMS UI uses, found at runtime), as the
CMS's first-admin registration does:
opticli users roles # the roles, how many users each has, and what CmsAdmins, CmsEditors, ... map to
opticli users add dev --dry-run # the name, the password against the site's rules, the roles
opticli users add dev # in WebAdmins (--role, repeatable, for others); a missing role is created
opticli users remove dev # only a user opticli made
| Command | Does |
|---|---|
users add <name> [--role R]... [--password-stdin] | makes the user, approved, with the address <name>@opticli.localhost (it reaches nobody) and the claim opticli:created, in WebAdmins (the role the first-admin registration uses) unless --role says otherwise; a role that doesn't exist is created (createdRoles). A warning says when the roles don't give CmsAdmins (admin mode) on this site. The password: --password-stdin reads one line; on a terminal opticli asks without showing it; otherwise it generates one and writes it to a file only you can read (passwordFile, see Files), never to the output. If the site's answer gets lost, the file is kept as <name>.txt.<id>.pending and the error names it (the site may have made the user); a later run for the name doesn't remove it, users remove does. The site's password rules apply (validation, exit 5). A name that exists is a conflict. |
users remove <name> | removes a user users add made (the claim opticli:created with the value true and the opticli.localhost address; in a user store without claims, the address), and its password files. Any other user is refused (exit 3); roles stay. |
users roles | role names with their member counts, the virtual roles each gives, the virtual roles (mapped ones with the roles that give them), and how many users opticli made. No user names or addresses. |
All three go through the site (serve) and take no part of an existing account. Against a shared database they are
refused (exit 3), even users roles: its users are real, and opticli doesn't even count them there. On a site whose
CMS users come from OpenID Connect, Opti ID or another identity provider (no ASP.NET Identity), they are refused (exit
3) with the user provider the site has: sign in through that provider. doctor doesn't hint at users add: whether
anyone can sign in locally depends on password hashes and external logins in the user tables, which are personal data.
When a content type's class or a property is removed from the code, the CMS deletes it from the database when the site
next starts, but only if nothing uses it. A type with content (also in the recycle bin), or a block type a property
still has, stays; so does a property with stored values, marked existsOnModel: false. Old databases collect years of
them, and admin mode removes them one at a time. types --orphaned and type <name> show them; these commands remove
them through the site (opticli serve), with the CMS's own IContentTypeRepository.Delete and
IPropertyDefinitionRepository.Delete (on CMS 13 the type saved without the property), as admin mode's Delete does.
On CMS 13, types of unknown origin go only with --include-unknown-origin (see
Content types on CMS 13):
opticli types prune --dry-run # what would go and what stays (kept), with why
opticli types remove OldNewsPage OldTeaserBlock # these types; all are checked first, or none goes
opticli types remove-property ArticlePage OldIntro --dry-run # the values that would go with it
opticli types prune --properties --allow-destructive --dry-run
| Command | Does |
|---|---|
types remove <type>... | removes content types whose class the running site can't load. Refused (exit 3): a type made in admin mode (no class on record), one of the CMS's own, one whose class the site loads. conflict (exit 5): content of the type, also in the recycle bin, inline blocks of it in ContentAreas, a property that has it as its block type (unless that property's type goes in the same run: it is removed first), or page-type properties whose values name it (the CMS would clear those values; the error lists the versions holding them, at most 20). Every named type is checked before any is removed; details.validation has each one that can't go. |
types remove-property <type> <property>... | removes properties that aren't in their type's code (existsOnModel: false), on a type defined in code. The CMS deletes the values with them, in every version and language, values inside a block property and category selections included: a property with values needs --allow-destructive (refused, exit 3, otherwise), which nothing implies. A property a content provider (a catalog, a DAM) uses is a conflict (exit 5) with no override: opticli can't count or see those values, and the provider may keep them outside the CMS's tables. A property added in admin mode to a type that has a class looks the same in the database, so check before removing one; a property of a type made in admin mode is refused. |
types prune [--properties] [--allow-destructive] | every orphaned type that can go, and with --properties every orphaned property (with values only with --allow-destructive); kept lists the rest with the reason and the counts. A block type whose only users are properties or types removed in the same run goes after them. |
The output records what was removed, to make it again by hand: each type's name, GUID, base, class, display name and
properties (data type, block type, culture-specific, required, tab, ...), the types it allowed below it and those that
allowed it, and each property's stored values (content items and versions); on a terminal every field is shown in
full. Content types and properties aren't versioned and there is no undo, so the site also writes each record, just
before the CMS removes it, as one JSON line to removals.jsonl in opticli's state directory ($XDG_STATE_HOME/opticli/,
%LOCALAPPDATA%\opticli\ on Windows; never rotated; recordFile in the output), with the time, the site and the
database, and the same line to its output (serve --logs). A removal the CMS then refuses gets a line saying so. The
record is written by the site itself, so it is complete however the command ends (a timeout, Ctrl+C: the site removes
nothing more once the CLI stops waiting). A run that stops halfway fails with the records of what it did remove in
error.details.removed. Each item is checked once more just before it goes (its values, what uses it), and the run
stops there if that changed; one removal runs at a time per site. The CMS records a type's removal in its activity log (tblActivityLog, type
ContentType), not a property's. The site serve runs uses the new content model at once; another process running the
site against the same database (your IDE's) keeps its cached content types until it restarts. With
IDE mode the IDE's site is the one that removes them.
Without serve these commands are unreachable (exit 4): only the running site can tell which classes it loads, and
there is no database fallback for removing. Against a shared database they are refused (exit 3), with no override: the
deployed site uses the same content model. apply has no such steps, and the MCP module no such tools.
The same opticli reads and writes CMS 13 sites (EPiServer.CMS 13.x on .NET 10), both fresh installs and databases
upgraded from CMS 12. Reads choose their SQL from the database's schema. serve and env inject the site agent built
for the site's CMS major (agent/cms12/, agent/cms13/), taken from the project's packages, else from the database's
schema version; doctor shows both as cmsMajor. A CMS 13 build against a CMS 12 database is refused (exit 3): the
CMS would upgrade the database at startup, and CMS 12 can't use it afterwards. To upgrade it on purpose, start the site
once yourself. The reverse is refused too.
CMS 13 keeps its sites as applications. The old site definition tables are empty on a fresh install and stale after
an upgrade, and opticli doesn't read them there. sites lists the applications in the same shape, without guid,
plus application (the name that identifies it: e.g. alloy13, or Site_<old GUID> after an upgrade),
applicationType (inProcessWebsite, or website for a headless front end) and isDefault. Commands take a site by
its name, id or application name.
* host did. sites host add <site> "*"
makes an application the default and takes it from the previous one in the same save (the result lists both).
resolve of a host nobody has shows host: null.--keep-site-url and --https unset are usage errors.--https gets http on localhost, *.localhost and
loopback addresses (what serve listens on), else the scheme of the site's URL. The upgrade makes every host that
had no https setting https, so an upgraded site's absolute URLs are https.--type preview and --type media add CMS 13's preview and media hosts (one of each per application, for every
language; preview hosts only on a headless application). CMS 12 refuses them.The database has only the job's class name (PageArchiveJob). jobs shows the name admin mode shows: for the CMS's
own jobs from a table built into opticli (the names CMS 12 stored), for the site's own from
[ScheduledJob(DisplayName = ...)] in its code, else the class name. Either name works wherever a job is named.
experience, section, element and contract (an
interface other types implement). --kind page includes experiences and --kind block sections and elements; the
others match only themselves. types adds compositionBehaviors, contracts, blueprints and inlineUses (inline
blocks of the type in content's current versions); type adds implementedBy for a contract and the
displayTemplates the type's content can use.types --orphaned lists it with originUnknown: true, and types remove, remove-property and prune remove it only with --include-unknown-origin. Without serve, types --orphaned also reads the GUIDs of the classes in the site's build output, so build it first.informational: true and don't stop
writes.An experience is a page made of sections. A section holds rows, a row columns, and a column elements (blocks with the ElementEnabled composition behaviour). Sections and elements are mostly inline: stored in the experience, with no content of their own. An element can also be a shared block, placed by reference. opticli reads compositions from the database, and writes them through the CMS's own composition API, which keeps each node's key.
opticli get 104 --fields composition # sections → rows → columns → elements: keys, names, types, styles, values
opticli get 104 --text # the same as a tree
opticli display-templates --type TextElement # the styles (display templates and settings) a node of the type can use
opticli where-used 103 # names the experience, section and element a shared block is in
get of an experience (or a section) shows composition instead of the two properties it is stored in, Layout and
UnstructuredData (--fields Layout shows the stored value). Each node has key, name and type, styles as
displayTemplate and displaySettings, and an inline block's properties or a shared block's content. A
section-enabled block in the outline is listed in sections with "nodeType": "component". search and where-used
name the section and element a match is in, where-used --type adds a row (inline: true) for the type's inline
blocks, and allowed-in lists where a composition takes a type (property: "composition").
{"ref": "104", "type": "LandingExperience", "kind": "experience", "version": "104_105",
"properties": {"Summary": {"type": "LongString", "value": "Spring campaign"}},
"composition": {"layout": "outline", "culture": "en", "sections": [
{"key": "1b1ef8e9-…", "name": "Body", "type": "TwoColumnSection", "inline": true, "displayTemplate": "wide", "rows": [
{"key": "2715f4d1-…", "name": "Row", "columns": [
{"key": "f7c9491b-…", "name": "Left", "elements": [
{"key": "5faf72ec-…", "name": "Intro", "type": "TextElement", "inline": true,
"properties": {"Heading": {"type": "LongString", "value": "Hello"}}},
{"key": "7a05f524-…", "name": "Shared", "type": "TextElement",
"content": {"ref": "103", "type": "TextElement", "kind": "element", "name": "Shared text", "status": "published"}}]}]}]}]}}
Writes need serve, save a draft unless --publish, and take --dry-run:
opticli composition 104 add section --type TwoColumnSection --name Hero --template wide --setting background=dark
opticli composition 104 add row --in Hero
opticli composition 104 add column --in <row key> --name Left
opticli composition 104 add element --in Left --type TextElement Heading="Hi" [email protected]
opticli composition 104 add element --in Left --ref 103 # a shared block, by reference
opticli composition 104 set Hero --setting background=light # --setting key= removes one, --template "" clears
opticli composition 104 move <element key> --in Right --at 0
opticli composition 104 remove Hero
opticli set 104 [email protected] --dry-run # the whole composition, as get shows it
opticli create 6 --type LandingExperience --name Spring [email protected]
root is the composition itself.display-templates), as errors where the CMS only warns.set, create, a plan) sets structure, order, names and styles as given. A node with a key
keeps its block, and only the properties given change, as with set; a node without a key is new, and nodes left
out are removed. So get's output can be edited and written back; a value get cut short is refused (--full).composition: added, removed, moved, changed). Plans have an op
composition with the command's fields (opticli apply --help).Content variations are versions of their own, e.g. for a campaign, that store only what they change over the
published version. versions marks them with variation, get --variation <key> shows one as the CMS loads it
(--version latest: its newest), and drafts and find --status draft count a variation's drafts (variation,
variationDrafts). set and composition with --variation <key> save a version of that variation, made from the
published version when it has none yet; publish <id>_<version> publishes it.
Blueprints are templates new content is made from. create <parent> --blueprint <ref|name> --name <name> makes
content with the blueprint's type, values and composition (one language, one version; dry runs, --guid and plans
work), and composition <ref> add section --blueprint <name> adds a section from a section blueprint, with new keys.
opticli doesn't make or change blueprints or display templates.
services.AddVisitorGroupsMvc()); without that, a ContentArea
item with visitorGroups is refused ("No visitor group has the id ...").validation issue with
code: "unresolvedReference"). So apply refuses up front a plan whose rich text links to content a later step
creates: reorder it, or add the link in a later set./Optimizely/CMS/.OptiCli.Mcp.Cms13 package on CMS 13: see
MCP server for editors.opticli serve runs the site's existing build output in Development on http://127.0.0.1:<port>, with the site
agent injected, and returns once the site agent answers.
| Option | Default |
|---|---|
--output <dll> | the newest bin/{Debug,Release}/<tfm>/<AssemblyName>.dll (also below a <rid>/ folder, under the project's OutputPath/BaseOutputPath, or under artifacts/ with UseArtifactsOutput), or output from the user config |
--port <n> | port from the user config, else 5199, else the first free port up to 5299 |
--build | off: opticli warns when sources (the site's or a referenced project's) are newer than the build, and --build runs dotnet build first |
--timeout <s> | 180 seconds to wait for the site to answer |
--foreground | off: the site runs in the background; with it, the site's output streams until Ctrl+C (to stderr when stdout is redirected, so stdout stays one JSON envelope) |
--https | off (or "https": true in the user config, which --https false overrides): also listen on https://localhost:<next free port> with the development certificate, printed as browseUrl, for sites that redirect to HTTPS. serve warns when a site does |
--scheduler | off (or "scheduler": true in the user config, which --scheduler false overrides): the site's scheduler stays as the site sets it, so jobs run on their schedule and overdue ones start at once. Without it the scheduler is off for the run. A usage error against a shared database, where it stays off |
--allow-pending-migrations | off: against a shared database, serve refuses a build with EF Core migrations the database lacks |
serve --status, serve --logs [--tail N] and serve --stop manage the running site:
--stop asks the site to shut down through the site agent, on every OS, and falls back to SIGTERM on Linux and
macOS. A site that hasn't exited after 20 s is killed. On Windows that kill is all there is when the agent doesn't
answer.--logs reads the latest from its end, and data.previous lists the two before it.--status and --stop remove it, doctor reports it.serve runs for the same project don't start two sites: the second waits for the first and reports its site.
serves for different sites started at the same moment don't pick the same free port either (best effort: they
share their claims through $XDG_RUNTIME_DIR, else ~/.local/state/opticli/ports, so serves run with different
values of those don't see each other's).serve waits for the site stops the site again.Kestrel:Endpoints ignores ASPNETCORE_URLS. serve and env then add opticli's address
as one more endpoint (Kestrel__Endpoints__OptiCli__Url) and warn. If the site sets its addresses in code, a
timeout names the addresses it listens on instead.To run the site yourself (IDE, dotnet run, hot reload), opticli env prints the variables serve would set:
--format shell|powershell|dotenv|json|launchSettings picks the format. --json is the same as --format json.--port picks the port.OPTICLI_SCHEDULER=on): that run is yours.OPTICLI_* variables that this run leaves out but the shell still exports (from an earlier opticli env) are set
to empty, so they can't pin or approve another database.The connection string is left out unless --include-connection is given. Without it the site uses its own
configuration, which is still checked to be local (or the approved development database) and the same database
the CLI reads.
Set the variables for the site process only, e.g. in a subshell:
(eval "$(opticli env)" && dotnet run --no-launch-profile)
DOTNET_STARTUP_HOOKS makes every .NET process started from a shell that exports it load the hook. Launch profiles
are often committed, so keep the token out of git.
| Variable | Set by serve/env for the site | Meaning |
|---|---|---|
DOTNET_STARTUP_HOOKS | always | path to OptiCli.Agent.dll, which loads the site agent |
OPTICLI_TOKEN | always | the per-run token the site agent requires |
OPTICLI_DB | serve, env --include-connection | the connection string to pin the site to (read by the CLI, it is the same as --connection) |
OPTICLI_CONNECTION_NAME | when not EPiServerDB | which connection string to pin |
ConnectionStrings__<Name> | serve, env --include-connection | the same connection string, for code that reads it before the agent's pin; serve also removes other spellings of it (ConnectionStrings:<Name>, another case) |
OPTICLI_REMOTE_DB | against a remote development database | the one remote database the site agent accepts; turns on shared-database mode |
OPTICLI_DRIFT_FILE | serve, against a remote development database | what serve compared before the start (EF Core migrations, CMS schema version), for the site agent's drift report |
OPTICLI_REMOVALS_FILE | never (the edge-case test site sets it) | another file for the record of types remove, remove-property and prune, instead of removals.jsonl in opticli's state directory |
OPTICLI_SCHEDULER | serve --scheduler, env (not against a remote database) | on leaves the site's scheduler as the site sets it; without it the site agent turns the scheduler off |
serve runs a second process of the site. The process you started from your IDE keeps its own content cache, and
nothing tells it that opticli changed the database, so it shows the old content until you restart it. opticli ide on
fixes that: the site you start from the IDE (Rider's run or debug, Visual Studio, dotnet run, dotnet watch) loads
the site agent from its own build, so writes go through that process and show there at once.
opticli ide on # once per project (again after updating opticli)
# rebuild, then start the site from the IDE as usual
opticli ide status # the block, the agent copy, whether the build has the hook, the running site
opticli ide off
ide on adds a labelled ItemGroup to <Project>.csproj.user, which MSBuild imports on its own and repositories
normally ignore. Its RuntimeHostConfigurationOption items put the agent into the build's runtimeconfig.json as a
startup hook (only for Debug, --configuration picks another, and never when publishing). Nothing else in the file
changes. A .csproj.user that git tracks is refused; one git doesn't ignore is added to .git/info/exclude, which is
never committed (ide off removes that line again).agent/<version>-<hash>/cms<major>/): the tool's own
path changes with every update, and a startup hook that is missing stops the site from starting. The block only adds
the hook while the copy exists. ide on removes copies older than 30 days other than the current one.<project>.ide.json in the state directory (readable by you only), and removes it when the site stops. A file left by
a site the IDE killed is ignored and removed. Nothing secret goes into the project, the build output or a launch
profile.localhost, loopback or wildcard addresses,
and names below .localhost such as site.dev.localhost): plain HTTP first, else HTTPS on localhost, accepting
the ASP.NET Core development certificate there (also on Linux, where OpenSSL doesn't trust it).serve turns those off there). If
the site runs against a database other than a local one or the development database, or outside Development, the
agent stays off for that run (an [opticli] IDE mode: the agent is off line in the site's output) instead of
stopping the site. Writes to a remote development database still stop on drift.serve --status, doctor and every write use the IDE's site while it runs (mode: ide). serve then starts nothing
and says so. When the IDE's site doesn't answer (paused at a breakpoint), writes say so; continue it.serve still works when the IDE's site isn't running: it runs the same build, and gives the site the agent copy that
build names, so one agent loads. opticli env is refused while IDE mode is on.serve starts dotnet <Site>.dll with DOTNET_STARTUP_HOOKS pointing at OptiCli.Agent.dll, which ships in the
tool package under agent/, one build per CMS major (agent/cms12/, agent/cms13/): serve and env take the one
for the CMS the project builds against (else the one its database's schema belongs to). The startup hook makes the site agent's assembly resolvable and appends it to
ASPNETCORE_HOSTINGSTARTUPASSEMBLIES. The hosting startup then:
PostConfigure of the CMS's data
access options;SchedulerOptions.Enabled, as a PostConfigure) unless OPTICLI_SCHEDULER=on; a site
that turns it on in a PostConfigure of its own wins, and serve --status then says scheduler: on;/_opticli/v1/* ahead of the site's own middleware.The site agent is compiled against EPiServer.CMS.Core 12.0 (.NET 8) and 13.0 (.NET 10), one build each, and binds to
the site's own, newer CMS assemblies at runtime. It ships no copies of them.
{"ok": true, "data": …, "meta": {"source", "version", "next", "warnings", "database"}}.
source is db for reads, agent for writes and cli for local commands.database is present only when the database is remote.--json and --text force either.--jsonl on list commands prints one item per line. A final {"meta": {…}} line follows only when there is a
next page or warnings.sql: 100 rows). Pass meta.next as --cursor for the next page, or raise
--limit. There is no total count.truncated: true, length: N); --full or --fields gives them whole.
Dates are UTC (…Z).{"ok": false, "error": {"code", "message", "hint", "details"}}. The hint says what to do next.| Exit | Code | Meaning |
|---|---|---|
| 0 | ok | |
| 1 | usage / internal | bad arguments (or a bug in opticli) |
| 2 | not_found | project, connection string, content, type or version not found |
| 3 | refused | a safety rule blocked it |
| 4 | unreachable | database or site agent not reachable, or a query failed on the server (a write that timed out may still have been saved) |
| 4 | login_failed | the database server answered but refused the login: the identity has no user in the database (error.details.identity names it, also for Microsoft Entra), a wrong password, or a database that doesn't exist |
| 4 | timeout | jobs run --timeout ran out: the job goes on running |
| 5 | conflict / validation / drift | a newer version exists, a publish would include someone else's unpublished changes (details.reason: "pendingDraft"), the CMS rejected the values, or a write against a shared database that differs from the build wasn't confirmed (details is the drift report) |
| 6 | needs_selection | the user must choose the development database first (error.details.choices) |
| 7 | job_failed | a job jobs run ran didn't succeed: it failed, couldn't start, or was stopped or aborted (error.details has its status and message) |
| 130 | cancelled | interrupted (Ctrl+C); an apply reports what it saved until then |
| What | Linux / macOS | Windows |
|---|---|---|
| User config | $XDG_CONFIG_HOME/opticli/config.json (default ~/.config/opticli/config.json) | %APPDATA%\opticli\config.json |
serve state, start lock and logs (the last 3 runs) | $XDG_STATE_HOME/opticli/ (default ~/.local/state/opticli/) | %LOCALAPPDATA%\opticli\ |
IDE mode: where the IDE's site says it listens, with its token (<project>.ide.json, written and removed by the site), and opticli's copies of the agent that builds name as their startup hook (agent/) | $XDG_STATE_HOME/opticli/, readable by you only | %LOCALAPPDATA%\opticli\ |
Generated passwords of users add | $XDG_STATE_HOME/opticli/users/<project>/<name>.txt, readable by you only (the folders too) | %LOCALAPPDATA%\opticli\users\<project>\<name>.txt, in your user profile, which other users can't read by default (opticli sets no ACL of its own) |
What types remove, remove-property and prune removed (one JSON line each, never rotated) | $XDG_STATE_HOME/opticli/removals.jsonl, readable by you only | %LOCALAPPDATA%\opticli\removals.jsonl |
The sync state of sync pull, one file per project, source and local database (deleting one takes a new baseline, which writes the source's content over published local changes) | $XDG_CONFIG_HOME/opticli/sync/<hash>.json, readable by you only | %APPDATA%\opticli\sync\<hash>.json |
| Seals for production, their key, and the mark that one was run | $XDG_CONFIG_HOME/opticli/seals/<id>.json and <id>.used, $XDG_CONFIG_HOME/opticli/seal.key, readable by you only | %APPDATA%\opticli\seals\, %APPDATA%\opticli\seal.key |
| A sealed apply's site log and what was compared before it started | $XDG_STATE_HOME/opticli/sealed/<id>.log, <id>.drift.json, readable by you only | %LOCALAPPDATA%\opticli\sealed\ |
You can set per-project defaults in the user config (output, port, https and scheduler for serve).
opticli db use adds the chosen database there, opticli db add an entry to databases (and its role to roles,
which holds the roles of every project's databases), and opticli sites primary --save the sites.primary mapping.
opticli keeps the file's permissions when it rewrites it, and creates it readable by you only on Linux and macOS. A
roles entry opticli can't read stops it from using any database until it is fixed.
{"projects": {"/abs/path/to/Site": {"connection": "...", "output": "bin/Debug/net8.0/Site.dll", "port": 5199, "https": true,
"databases": {"prod": {"connection": "Server=...;Database=...;User Id=reader;Password=..."}},
"sites": {"primary": {"Site A": "localhost:5001", "Site A@nb": "https://localhost:5004",
"Site B": {"host": "localhost:5002", "keepEdit": true}}}}},
"roles": [{"server": "sql.example.com", "database": "site", "role": "prod", "setAt": "2026-10-15T09:00:00Z", "setVia": "add"}]}
EPiServer.CMS.AspNetCore 12.x): reads on any runtime; writes on a site running .NET 8 or newer.EPiServer.CMS 13.x, .NET 10), fresh installs and databases upgraded from CMS 12: reads and writes,
Visual Builder included (see CMS 13).OptiCli.Mcp on CMS 12 (12.12.1 / CMS UI 12.16.1 or newer, .NET 8 or newer),
OptiCli.Mcp.Cms13 on CMS 13 (13.0.0 or newer); see MCP server for editors.[AllowedTypes] is parsed. Editor descriptors and metadata extenders that change allowed types at runtime are
not; a uiHint flags the property.type lists views, but not controllers.where-used works per item: there is no type-wide usage in one call.serve runs the existing build output; it doesn't build unless --build is given.The MCP module is a separate NuGet package for the site itself, not for developers' machines. Installed in a site, it
lets editors connect an AI assistant that supports MCP servers (Claude, or another MCP client with an OAuth
sign-in) to that site, in any environment, production included. The editor signs in with the site's own login, and the
assistant then works as that editor, with their access rights in the CMS. The tools run the same content operations as the site agent: drafts by default, dry runs with a
list of changes, validation, approval sequences, baseVersion conflicts, and errors with a hint.
It comes as two packages, one for each CMS version, released together with the same version as the CLI:
| The site runs | Package |
|---|---|
CMS 12 (EPiServer.CMS.Core 12.12.1 and EPiServer.CMS.UI.Core 12.16.1 or newer), on .NET 8 or newer, .NET 10 included | OptiCli.Mcp |
| CMS 13 (13.0.0 or newer) | OptiCli.Mcp.Cms13 |
Both are built from the same code and set up the same way: the same AddOptiCliMcp, MapOptiCliMcp and options, the
same endpoints and tools. What differs on CMS 13 is under On CMS 13.
dotnet add package OptiCli.Mcp # CMS 12
dotnet add package OptiCli.Mcp.Cms13 # CMS 13
In Startup.cs, the same for both:
using OptiCli.Mcp;
public void ConfigureServices(IServiceCollection services)
{
// ... AddCms() and the rest
services.AddOptiCliMcp(o =>
{
o.AllowPublish = true; // optional: off by default
});
}
public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
{
// ... UseRouting(), UseAuthentication(), UseAuthorization()
app.UseEndpoints(endpoints =>
{
endpoints.MapContent();
endpoints.MapControllers(); // the site's own endpoints, if it has them
endpoints.MapOptiCliMcp(); // after everything else
});
}
Map the module after MapContent() and the site's own endpoints. Its paths are literal, so they win over content
routing's catch-all wherever they are mapped, and mapped last it changes nothing for the rest. Mapped before
MapContent() it does: the CMS's documentation asks for other endpoints before or after MapContent(), not both,
because with anything mapped before it, the CMS freezes the routes add-ons register, and a MapControllers() after it
then maps the site's controllers a second time. A site with a named route then fails every request with "Duplicate
endpoint name". The module logs a warning when it is the site's first endpoint mapping.
The module adds a bearer scheme of its own for the MCP endpoint, and leaves the site's default authentication scheme as it is: editors sign in with whatever the site uses (ASP.NET Identity, Microsoft Entra ID, Opti ID). Connections are kept in the Dynamic Data Store, and access tokens (and the assistant's preview links, if the site allows them) are protected with the site's Data Protection keys, which every instance of a load-balanced site already shares for its login cookies.
Requirements: OptiCli.Mcp needs .NET 8 or newer, EPiServer.CMS.Core 12.12.1 or newer and EPiServer.CMS.UI.Core
12.16.1 or newer (the first CMS 12 versions whose dependencies allow the MCP SDK's Microsoft.Extensions 10.x
packages). A CMS 12 site on .NET 10 uses it too: the package goes by the CMS version, not by .NET's.
OptiCli.Mcp.Cms13 needs .NET 10, EPiServer.CMS.Core and EPiServer.CMS.UI.Core 13.0.0 or newer, and brings
EPiServer.ImageLibrary (the image service interface view_media scales images through). Both bring the official MCP
C# SDK (ModelContextProtocol.AspNetCore 2.2.0).
The wrong package for the site's CMS is usually refused by NuGet already, as the CMS packages' versions conflict or
there is no build for the site's .NET version. Where it isn't, the site stops at startup, in AddOptiCliMcp, with an
error that names the right package:
System.InvalidOperationException: This site runs CMS 13: use the OptiCli.Mcp.Cms13 package instead of OptiCli.Mcp.
(or "This site runs CMS 12: use the OptiCli.Mcp package instead of OptiCli.Mcp.Cms13.").
Moving a site from CMS 12 to 13 is one package swap: replace OptiCli.Mcp with OptiCli.Mcp.Cms13 at the same
version, and leave Startup.cs and the OptiCli:Mcp configuration as they are. Connections are kept in Dynamic Data
Store stores with the same names on both, so they carry over when the database's store data does; otherwise editors
connect their assistant once more after the upgrade.
Everything else under MCP server for editors holds on CMS 13 too. What is different there:
get_content of an experience gives its composition: sections, rows, columns and elements, with
their display templates and settings and the elements' properties. It follows the same rules as every other read:
properties the edit UI hides or locks are left out (an element's tabs are shown as the edit UI shows them, as a local
block's are), and a shared block or section the editor can't read shows only its place in the composition (nodeType,
key and a ref they can't open), never its name, type or values. Dry runs and pending drafts list a composition's
changes by the same rules, and leave out a change made only inside content the editor can't read. Editors can't
change compositions through the MCP server yet: an experience's other properties can be changed, and Layout is
refused. Developers change compositions with opticli composition (see Visual Builder).editUrl and previewUrl come from the CMS, so they open CMS 13's edit UI, under /Optimizely/CMS/
instead of /EPiServer/CMS/. The module's own paths (/episerver/opticli/...) are the same on both.MediaFileOptions (EPiServer:Cms:MediaFile), which CMS 13's edit UI uses in place of
the CMS UI's upload settings: its FileSizeLimit (30,000,000 bytes by default; the lower of it and MaxUploadBytes
applies) and its AllowedFileExtensions, matched as the edit UI matches them, whole and with the dot (.jpg; jpg
allows nothing). After the module's own checks, a file goes through the CMS's script parser, as an upload in the edit
UI does (for the extensions in its MediaExtensionsToParse: .svg, .svgz, .html and .htm by default): where
the parser refuses the file, so does the module, with the parser's message; otherwise the module stores the file as
the parser wrote it back, as the edit UI does, once that is checked for script again.javascript: links in rich
text, URL and link properties on save by default, and removes them when content is loaded. The module's own checks
still answer first, with their own message, and cover what the parser doesn't (<iframe>, , forms,
links and the rest).The options are read from the OptiCli:Mcp configuration section, then from the delegate passed to AddOptiCliMcp,
which wins. In configuration, an AllowedRoles or AllowedRedirectHosts list replaces the default list.
| Option | Default | Meaning |
|---|---|---|
BasePath | /episerver/opticli | where the module lives: the MCP endpoint is {BasePath}/mcp, the OAuth issuer {origin}{BasePath}; "" for a host of its own (with RequireHost) |
RequireHost | none | only answer on this host name (host or host:port), e.g. an editors' host |
AllowedRoles | WebEditors, WebAdmins, CmsEditors, CmsAdmins, Administrators | who may connect an assistant at all; the CMS's access rights then decide per item |
AllowedRedirectHosts | claude.ai, claude.com | the hosts an app's return address (OAuth redirect URI) may be on, https only, matched exactly (no subdomains or wildcards); a loopback address on the editor's own machine is always allowed. ["*"] allows any https host, and logs a warning at startup (see below) |
AccessTokenLifetime | 1 hour | how long an access token works; also how long a removed role can keep working |
RefreshTokenLifetime | 30 days | how long a connection survives unused; each refresh extends it |
ConnectionLifetime | 30 days | the longest a connection lasts, however often it is used; then the editor connects again, through the site's login (see below) |
RefreshTokenReuseGrace | 1 minute | how long after a refresh the refresh token it replaced, sent again by the same client, is only refused; later (or from another client) it revokes the connection |
AllowPublish | false | let assistants publish, unpublish and schedule publishing (the content:publish scope) |
AllowDelete | false | let assistants delete content, always to the recycle bin |
AllowPreviewLinks | true | give the assistant a short-lived link of its own to a draft's preview, which it can open without a sign-in (assistantPreviewUrl, see below); false turns it off |
{"OptiCli": {"Mcp": {"AllowDelete": true, "AllowedRoles": ["WebEditors", "WebAdmins", "ProductEditors"]}}}
AllowedRedirectHosts is what stops a stranger from connecting an app to an editor's account. Registration is open to
anyone and an app names itself, so without it someone could register an app called "Claude" with a return address of
their own, send a signed-in editor the link to connect it, and receive the code that the editor's Allow sends there.
The module checks the return address when an app registers and again at every sign-in, also for apps with a metadata
document and for apps registered before the list changed. Add a host only for a web app the site knowingly accepts.
RateLimits are fixed windows of a minute, counted per instance; over a limit, a request gets 429 with Retry-After.
claude.ai calls register and token from Anthropic's addresses (below), which every editor of the site who uses
claude.ai shares, so the token endpoint counts per client as well as per address. An IPv6 address counts by its /64,
which one machine usually has whole. Behind a proxy or load balancer, the site needs ASP.NET Core's forwarded headers
set up (as its login usually already does), limited to its known proxies: otherwise every request seems to come from
the proxy, and everyone shares one window.
RateLimits. | Default | Counts |
|---|---|---|
TokenPerMinute | 60 | token requests (code exchanges, refreshes) per client and address |
TokenPerAddressPerMinute | 600 | token requests per address, whatever the client |
RegisterPerMinute | 10 | client registrations per address (clients with a metadata document, as Claude has, don't register) |
AuthorizePerMinute | 60 | the consent page and its post, per address (the editor's browser) |
PreviewPerMinute | 60 | opened preview links (AllowPreviewLinks), per connection |
InvalidPreviewPerAddressPerMinute | 120 | requests for preview links that aren't valid (malformed, expired, for another host), per address |
Besides the rate limit, registration stops at 5,000 registered apps that have no connection and no sign-in under way:
register then answers 429 until the cleanup has deleted some. An app that registers and doesn't sign in within a day
is deleted.
{"OptiCli": {"Mcp": {"RateLimits": {"TokenPerAddressPerMinute": 1200}}}}
With the default BasePath, on the site's own host:
| URL | What it is |
|---|---|
https://www.example.com/episerver/opticli/mcp | the MCP endpoint (Streamable HTTP): the URL to give the assistant |
/.well-known/oauth-protected-resource/episerver/opticli/mcp | protected resource metadata (RFC 9728) |
/.well-known/oauth-authorization-server/episerver/opticli | authorization server metadata (RFC 8414), also as /.well-known/openid-configuration/episerver/opticli and /episerver/opticli/.well-known/openid-configuration |
/episerver/opticli/oauth/register, /oauth/authorize, /oauth/token | client registration, sign-in and consent, tokens |
/episerver/opticli/connections | the editor's connections, with Revoke |
/episerver/opticli/preview/{payload} | the assistant's preview links, unless AllowPreviewLinks is turned off |
The site's own /.well-known documents at the root are left alone. A request to the MCP endpoint without a token
gets a 401 that points to the metadata, never a redirect to the login page.
A site can run another MCP server built on the official MCP C# SDK next to the module: its own, or a package's, such as
Optimizely's Optimizely.Cms.Opal.Tools on CMS 13. Map it at a path of its own (endpoints.MapMcp("/mcp")); its paths
and its sign-in don't clash with the module's. The SDK keeps one set of server settings for the whole site, so the
module sees to it that each endpoint serves only its own server:
/sse, /message), whatever the other server turns on.Stateless = false, registered
after AddOptiCliMcp, so its setting wins), the module's endpoint has sessions too, but takes only a session it made
itself, from the connection that made it: any other session ID gets 404 "Session not found", and the client starts a
new session. The module's sessions don't work at the other endpoint either. They are bound to the connection by a
sub claim with the connection's ID on the module's own sign-in, so site code that runs during a tool call (a
content event handler, say) sees that ID as sub. The module forgets sessions the SDK has dropped, and prunes the
rest to as many as the SDK keeps idle ones (MaxIdleSessionCount, at least 1,000), the least recently used first.AddOptiCliMcp, the other server's Stateless = false loses to the module's setting, and with
legacy SSE the site fails at startup with the SDK's own message: register it after AddOptiCliMcp.HttpServerTransportOptions.ConfigureSessionOptions hook, and runs
the other server's own hook on that server's endpoints, so set yours in WithHttpTransport(...) as usual. If code
registered after AddOptiCliMcp replaces the hook (a PostConfigure), the module's endpoint turns itself off rather
than serve without it: it answers every signed-in request with an error ("The site turned this MCP endpoint off…"),
and the site's log says why.Any MCP client that speaks Streamable HTTP and signs in with OAuth (authorization code with PKCE, identified by a client ID metadata document or registered dynamically) can connect: add a server with the MCP endpoint's URL. The first time it connects, the editor's browser opens the site's login page, then the module's consent page.
AllowedRedirectHosts (claude.ai and claude.com
by default), and call the site from their own servers, so the site must be reachable from there (below).https://www.example.com gets the
default path, and a full endpoint URL works for another BasePath or a dedicated host. The first time Claude uses it,
the editor's browser opens the site's login page, then the consent page. The extension connects from the editor's
own machine (through mcp-remote, which keeps the sign-in in
~/.mcp-auth), and nobody has to add a connector for the organization. Claude Desktop runs it with its own Node.js,
so editors install nothing else.claude mcp add --transport http cms https://www.example.com/episerver/opticli/mcp, then /mcp in
Claude Code to sign in.A Claude Desktop extension for one site has the address built in, so editors aren't asked for it, and a name of its own, so an editor can install several side by side (test and production); the generic one installs once. Build it from mcp-extension/ with Node.js 20 or newer:
cd mcp-extension
npm ci
node build.mjs https://www.example.com "Example CMS" # dist/opticli-cms-www-example-com.mcpb
node build.mjs without arguments builds the generic dist/opticli-cms.mcpb. Both have opticli's version, from
Directory.Build.props.
Trying it locally with Claude Code: plain http on a loopback address (http://127.0.0.1:5000/...) works where
the site allows it. Over https, Claude Code (a native executable) honours NODE_EXTRA_CA_CERTS but doesn't accept the
ASP.NET Core development certificate as a trust root, since that certificate is a self-signed leaf (CA:FALSE): it
fails with UNABLE_TO_VERIFY_LEAF_SIGNATURE, though curl and Node accept it. A localhost certificate signed by a test
CA of your own works:
openssl req -x509 -newkey rsa:2048 -nodes -days 30 -subj "/CN=Local MCP test CA" \
-addext "basicConstraints=critical,CA:TRUE" -addext "keyUsage=critical,keyCertSign,cRLSign" \
-keyout ca.key -out ca.pem
printf 'subjectAltName=DNS:localhost,IP:127.0.0.1\nbasicConstraints=critical,CA:FALSE\nkeyUsage=critical,digitalSignature,keyEncipherment\nextendedKeyUsage=serverAuth\n' > localhost.ext
openssl req -newkey rsa:2048 -nodes -subj "/CN=localhost" -keyout localhost.key -out localhost.csr
openssl x509 -req -in localhost.csr -CA ca.pem -CAkey ca.key -CAcreateserial -days 30 -extfile localhost.ext -out localhost.pem
# the site, with that certificate
ASPNETCORE_URLS=https://localhost:5443 \
ASPNETCORE_Kestrel__Certificates__Default__Path=$PWD/localhost.pem \
ASPNETCORE_Kestrel__Certificates__Default__KeyPath=$PWD/localhost.key \
dotnet run
# Claude Code, trusting the test CA
NODE_EXTRA_CA_CERTS=$PWD/ca.pem claude
Keep ca.key to yourself and delete it when you are done: whoever has it can make certificates your Claude Code trusts.
Nothing needs registering on the site first. A client with a client ID metadata document (CIMD), as Claude has, is identified by it: the site fetches the document from the client's own HTTPS URL, which needs outbound HTTPS from the site. Clients without CIMD register themselves (dynamic client registration, DCR).
The site must be publicly reachable for web-based assistants. Claude's custom connectors (claude.ai, Claude Desktop
and mobile), for one, call the site from Anthropic's cloud, not from the editor's machine, so a site that is only on a
VPN, a private network or split DNS can't be used with them. A site that restricts /episerver by IP address must allow Anthropic's outbound range,
160.79.104.0/21, for the MCP endpoint, the metadata documents and the register and token endpoints. The login,
authorize and the connections page open in the editor's own browser, so they need what the CMS login needs.
Assistants on the editor's own machine, such as Claude Desktop with the extension and Claude Code, connect from there
instead.
Dedicated host mode: BasePath = "" with RequireHost = "mcp.example.com" puts the module at the root of a host
name of its own (which must reach the same site). The issuer then has no path, which suits clients that don't handle
an issuer with one, and the root /.well-known documents on that host are the module's. RequireHost with the default
BasePath only keeps the module off the site's other host names.
not_found as content that doesn't exist, and lists leave it out. Every save, publish, move and
delete goes through the CMS's own access checks for the editor. The CMS records the editor as the one who saved.[ScaffoldColumn(false)]),
read-only ([Editable(false)]), on a tab whose required access they lack on the content (in an inline block or a
block list's item too, by the content it is in; not in a local block, which the edit UI shows whatever its tab), or
hidden or locked for them by an editor descriptor or metadata extender. The module asks the CMS UI's own metadata,
as the edit UI's form builds it (local blocks nested in their content's), and when that can't be built for an item,
the model's settings decide what is shown and nothing of the item may be changed. get_content leaves these
properties out, and so do diffs, also inside local blocks and block lists; writing them is refused, and a block list
rewritten keeps what its items had of them. get_content_type leaves out what the type's settings hide or lock;
what depends on the content and the editor only get_content knows;AllowPublish. So rich text with <script>, event handler attributes
(onerror=...), javascript:, vbscript: or non-image data: URLs, , , , ,
, , , and the like is refused, however it is written (character references, tabs or
odd case in a scheme, attributes without quotes), and so are links (link properties, link collections, URL
properties, URLs in a list property's items, a shortcut's external link) other than http, https, mailto, tel and
relative ones, and links with attributes other than href, title and target (the CMS renders every attribute a link
has), or with a control character in them. Links are given as , never as the CMS's
stored markup. Ordinary rich text passes: tables, images, links, embedded blocks, and . What a property
already has may be written back as it is, so an editor's assistant can still fix a typo in rich text with a video's
or a link collection with an sms: link; anything new is refused, also such a construct copied once more or
into another property. Only what a browser surely reads as live counts as already there: script inside an element it
reads as text or keeps inert (, , , , ...) is never written back live, nor a
live construct moved into one. Set the CMS's own script parser to remove script as well, for every save, the edit
UI's included (CMS 12.15 or newer): . CMS 13
already refuses such a save by default.UseStatusCodePages, UseStatusCodePagesWithReExecute) are switched off for the module's
own requests, and its errors all have a body, so the site's error page never replaces them. That matters most for
the MCP endpoint's 401: re-executed as a POST to a GET-only error page, it would reach the client as a 405 without the
WWW-Authenticate header that tells it where to sign in.X-Frame-Options: DENY, a Content-Security-Policy
with frame-ancestors 'none' and a form-action limited to the module and the client's redirect, and
Referrer-Policy: no-referrer. Middleware that sets a site's own security headers as the response starts replaces
them, and the module can't set its own later than that. The page also carries its policy and referrer policy in
meta elements, which the browser applies besides the site's header, but frame-ancestors and X-Frame-Options
only work as headers. So have such middleware leave {BasePath} (/episerver/opticli) alone, or only set headers
the response doesn't already have; or check that the site's own values forbid framing.Sec-Fetch-Dest: iframe, frame,
fencedframe, object or embed) gets a 403 page instead, and the consent and revoke posts are refused when the
browser says they came from another site or origin (Sec-Fetch-Site other than same-origin). A browser too old to
send these headers gets the page as before. The metadata documents are sent with Cache-Control: no-store.At /episerver/opticli/connections an editor sees the assistants they connected (app, the scopes they allowed, when
connected and last used) and can revoke them. WebAdmins and CmsAdmins see and revoke everyone's.
Every authorization decision, token issue, refresh, revocation, tool call and opened preview link is logged in the
OptiCli.Mcp.Audit category at Information level, with the user, the client, the tool, the content refs and the
outcome; never tokens, links, secrets or property values. A site that logs Warning and up by default needs
"Logging": {"LogLevel": {"OptiCli.Mcp.Audit": "Information"}} to keep them.
The MCP endpoint takes requests up to MaxUploadBytes as base64 plus 1 MB, and refuses larger ones with a 413; a file
larger than the CMS UI's own upload limit (EPiServer:CmsUI:Upload:FileSizeLimit: 4 MB by default up to CMS UI 12.31,
30,000,000 bytes from 12.33; on CMS 13 EPiServer:Cms:MediaFile:FileSizeLimit, 30,000,000 bytes) is refused too, as
the edit UI refuses it, so raise that as well for larger uploads. IIS has a limit of its own, maxAllowedContentLength,
30,000,000 bytes by default, so a site that raises MaxUploadBytes past about 20 MB must raise that too, in
web.config:
<system.webServer>
<security>
<requestFiltering>
<requestLimits maxAllowedContentLength="73400320" />
</requestFiltering>
</security>
</system.webServer>
dotnet build opticli.slnx # warnings are errors
dotnet test opticli.slnx
The EPiServer packages come from Optimizely's public NuGet feed, which nuget.config already lists.
Building needs the .NET 8 and .NET 10 SDKs: the site agent, OptiCli.Cms and OptiCli.Agent.Tests build twice, for
CMS 12 (net8.0, EPiServer.CMS.Core 12.0.3) and CMS 13 (net10.0, 13.0.0, with CMS13 defined), and CMS-specific code
stays in their Compat/ adapter files. The MCP module builds the same way, as two packages (OptiCli.Mcp for CMS 12
from 12.12.1, OptiCli.Mcp.Cms13 for CMS 13), and OptiCli.Mcp.Tests runs against each.
| Project | What it is |
|---|---|
src/OptiCli | the opticli command line: commands, options, help text |
src/OptiCli.Core | everything the CLI does: discovery, connection resolution and safety, SQL readers, decoders, output, serve |
src/OptiCli.Agent | the site agent, loaded into the site process; built for CMS 12 and CMS 13, both copied into the tool package (agent/cms12/, agent/cms13/) |
src/OptiCli.Cms | the content operations (read, create, draft, publish, ...) the site agent and the MCP module run, for a developer or a signed-in editor; source-linked into both |
src/OptiCli.Mcp | the MCP server for editors, the OptiCli.Mcp package (CMS 12): OAuth authorization server, tools, connections page |
src/OptiCli.Mcp.Cms13 | the OptiCli.Mcp.Cms13 package: the same sources built for CMS 13 (net10.0, CMS13 defined), with the same assembly name and public API |
src/OptiCli.Protocol | request and response types shared by the CLI, the site agent and the MCP module (source-linked) |
skill/ | the coding-agent skill, embedded in opticli.dll |
tests/ | unit tests for Core, the site agent and the MCP module (both on both CMS majors); OptiCli.Integration and OptiCli.Mcp.Integration, tests against a real site |
The unit tests use generic fixtures and need no database or site, also for CMS 13 (one, LocalDbTests, needs LocalDB
and skips itself elsewhere). The integration test is an oracle. It samples content
across types, kinds and languages from a real site, reads each item both from the database and through the CMS
(via the site agent), and compares them property by property. It is skipped unless a site is configured:
cd path/to/Site && opticli serve # the site agent must be running
OPTICLI_IT_PROJECT=path/to/Site \
OPTICLI_IT_SAMPLE=200 \
OPTICLI_IT_REPORT=/tmp/oracle-report.md \
dotnet test tests/OptiCli.Integration
| Variable | Meaning |
|---|---|
OPTICLI_IT_PROJECT | site project directory (required; the test is skipped without it) |
OPTICLI_IT_SAMPLE | content branches to compare (default 200) |
OPTICLI_IT_DRAFTS | most recent drafts to compare as well (default 10) |
OPTICLI_IT_SEED | sampling seed (fixed by default, so runs repeat) |
OPTICLI_IT_REPORT | write the mismatch report here, with every mismatch as .jsonl next to it |
OPTICLI_IT_PLAN | a plan whose content is always compared, on top of the sample (the edge-case plan below) |
Some differences the database can't reproduce by design, such as URL segments a site drops in code. Those are listed
with the reason in tests/OptiCli.Integration/Comparison/KnownDifferences.cs; any other mismatch fails the run.
A sample site lacks much of what real sites have: fetch-data pages, a site whose start page is under another site's,
simple addresses on several sites, culture-specific properties in shared and local blocks, personalized ContentAreas,
an approval sequence, language fallback settings, a media type for PDF files, three sites whose hosts the site host
tests change (and put back), and a scheduled job of its own. tests/fixtures/edge-cases/ builds them from an Alloy
site (dotnet new epi-alloy-mvc) without changing it. setup.sh copies the site and its database, adds
EdgeCasesFixture.cs (the extra content types, plus a startup module for what a plan can't create) and
JobsFixture.cs, UsersFixture.cs, OrphansFixture.cs and Cms12Fixture.cs (on CMS 13 Cms13Fixture.cs: the
CMS-specific parts, such as the hosts sites), and applies edge-cases.plan.json. JobsFixture.cs has "opticli test job", a manual, stoppable job
that writes a status message a second for 3 steps (the number in App_Data/opticli-job-steps, if it exists) and fails
when App_Data/opticli-job-fail exists; the jobs tests run it. OrphansFixture.cs leaves what removed code leaves behind: the page types EdgeRemovedPage (whose class doesn't
exist, used by one page), EdgeTrashedPage (its only page is in the recycle bin) and EdgeRemovedEmptyPage (used by
nothing, with a property Teaser whose block type EdgeRemovedBlock has no class either), the EdgePage properties
EdgeRemovedText (one value) and EdgeRemovedEmptyText (none) that its class doesn't declare, and EdgeAdminPage, a
type made in admin mode. It points OPTICLI_REMOVALS_FILE at the site's App_Data/opticli-removals.jsonl, so the
tests' removals stay out of your own removals.jsonl. They are made at the second start, once the plan's content exists, and again at every start
(the CMS removes an unused type whose class is gone when the site starts); the tests that remove them make them again
with POST /opticli-fixture/orphans, which the fixture answers on loopback only. UsersFixture.cs makes the user edge-fixture-user at startup (WebEditors, no password, not
made by opticli), which the users tests check users remove refuses; they also sign in on /util/login as a user
they add. With OPTICLI_FIXTURE_SCHEDULER=on in the environment
of serve, it also turns the scheduler on in the site's own configuration, as a real site has it (Alloy turns it off in
Development), to see that turns it off again and doesn't. Run again to update
the content: the plan is applied with , and the database copy is kept unless .
SQLCMDPASSWORD=... tests/fixtures/edge-cases/setup.sh path/to/Alloy path/to/AlloyEdge alloy alloy-edge
OPTICLI_IT_PROJECT=path/to/AlloyEdge \
OPTICLI_IT_PLAN=tests/fixtures/edge-cases/edge-cases.plan.json \
dotnet test tests/OptiCli.Integration
The integration tests run one at a time, since the write tests make and remove scratch content on the same site.
Tests of someone else's draft have the fixture save a version again as another user (POST /opticli-fixture/hand-over,
loopback only), through the CMS.
drift.sh in the same folder runs the shared-database scenario on copies of the edge-case site and its database. It
reaches the copy through a host name instead of a loopback name (<hostname>.localhost resolves to the loopback
address, but counts as remote), so serve runs in shared mode. It then changes the copy's code and checks each step:
nothing differs, local ahead, the database ahead, and an EF Core migration that serve refuses. Against that copy,
DriftTests checks that writes stop on drift until its fingerprint confirms it. It runs on CMS 12 and CMS 13 copies
alike.
SQLCMDPASSWORD=... tests/fixtures/edge-cases/drift.sh path/to/AlloyEdge path/to/AlloyDrift
tests/fixtures/cms13/setup.sh builds two CMS 13 Alloy sites: a fresh one (dotnet new epi-alloy-mvc, EPiServer.Templates
2.0.1, on an empty database the CMS fills at its first start; /demo/Alloy13, alloy13 by default) and an upgraded one
(the same code on a copy of a CMS 12 Alloy site's database and media, which CMS 13 upgrades at its first start;
/demo/Alloy13Up, alloy13-upgraded). The CMS 12 site and database it copies are only read. Both get
VisualBuilderFixture.cs: an experience type, section types, element types, a contract, display templates, and content
made through the CMS's API: two experiences (one with a Swedish branch), a shared element, blueprints, and a published
and a draft content variation. Run it again to update the fixture; FRESH=1 makes everything again, and the comment at
the top of the script lists the settings (CMS_VERSION, the folders, databases and ports).
The CMS 13 edge-case site is tests/fixtures/edge-cases/setup.sh run from the fresh CMS 13 site, and has the Visual
Builder fixture too. The integration tests run on all of them (VisualBuilderTests and VisualBuilderWriteTests where
the fixture is, the rest everywhere):
SQLCMDPASSWORD=... FRESH=1 tests/fixtures/cms13/setup.sh
SQLCMDPASSWORD=... FRESH=1 tests/fixtures/edge-cases/setup.sh /demo/Alloy13 /demo/AlloyEdge13 alloy13 alloy-edge13
(cd /demo/AlloyEdge13 && opticli serve)
OPTICLI_IT_PROJECT=/demo/AlloyEdge13 OPTICLI_IT_PLAN=tests/fixtures/edge-cases/edge-cases.plan.json dotnet test tests/OptiCli.Integration
Scripts in tests/fixtures/cms13/ run whole command sets against one site and save each output, with a summary.tsv:
read-battery.sh <site> <out> every read command (compare-battery.py <a> <b> compares two runs, e.g. an upgraded
CMS 13 site with the CMS 12 site it came from, or a CMS 12 site before and after a change), write-battery.sh the
content writes, and vb-write-battery.sh Visual Builder writes. The write batteries start and stop serve themselves,
check that the site renders what they wrote, and leave their content in the recycle bin.
tests/fixtures/sync/ makes databases to export from and to, as copies of an edge-case site's database (alloy-edge*
only; the base database and the site's own blobs are only read). make-source.sh makes the source,
alloy-edge-source, and changes it through opticli the way editors change another environment (source-changes.plan.json:
new pages with a cycle between two of them, a block in a "For this page" folder, media, an edited page, a draft, a
move, a page in the recycle bin, a new language branch; on CMS 13 also a Visual Builder experience). round-trip.sh
exports part of it and applies the plan to a fresh copy of the base database whose ids differ from the source's, then
compares every item and language branch the plan writes in both databases (get --full --all-properties, ids,
versions and who saved when aside; publish dates included), and applies it again, which must change nothing.
sync-pull.sh tests sync pull on copies of its own (alloy-edge-sync-source and alloy-edge-sync-target, ids
shifted): the baseline (a page in the target's recycle bin restored), then make-source.sh's changes and
sync-changes.plan.json's (a folder in a "For this page" folder, a page scheduled for 2030, a page of a type the target
lacks) pulled with --delete and compared item by item as round-trip.sh compares them, a second pull that applies
nothing, a conflict and --overwrite-local, a local change kept, a local trash that is a conflict until
--overwrite-local restores it, a language branch made in both, and a local draft behind values found the same. They all stop the sites they start; round-trip.sh drops its target unless
KEEP_TARGET=1, sync-pull.sh both copies unless KEEP=1. Each script's comment lists its modes and settings
(OPTICLI picks the opticli to run, e.g. a wrapper around this checkout's build).
SQLCMDPASSWORD=... tests/fixtures/sync/round-trip.sh # CMS 12: /demo/AlloyEdge, alloy-edge
SQLCMDPASSWORD=... tests/fixtures/sync/round-trip.sh /demo/AlloyEdge13 alloy-edge13 alloy-edge-13-source alloy-edge-13-target
SQLCMDPASSWORD=... tests/fixtures/sync/sync-pull.sh # CMS 12
SQLCMDPASSWORD=... tests/fixtures/sync/sync-pull.sh /demo/AlloyEdge13 alloy-edge13 alloy-edge-13-sync-source alloy-edge-13-sync-target
tests/fixtures/mcp/setup.sh builds a site for the MCP module's end-to-end tests from a copy of the edge-case site
and its database (alloy-edge to alloy-mcp by default; FRESH=1 copies the database again). It adds a project
reference to this checkout's src/OptiCli.Mcp, calls AddOptiCliMcp (publishing and deleting on, ProductEditors
allowed) and, as many real sites have it, UseStatusCodePagesWithReExecute("/error/{0}") and MapControllers() after
MapContent(), then MapOptiCliMcp() after those, in Startup.cs. It adds McpFixture.cs: a GET-only error page, an
API controller with a named route, a route an add-on registers with the CMS (together, what fails with "Duplicate
endpoint name" when the module is mapped before MapContent()), and four test
users (mcp-admin, mcp-editor, mcp-product, mcp-visitor) with generated passwords in
App_Data/mcp-test-users.json, editing rights for WebEditors from the root, and the "Alloy Meet" page hidden from
everyone but administrators and product editors, who may edit it but not publish it. It also adds a client ID metadata
document to wwwroot (fetched over http on loopback, which the module allows only in Development), then builds the
site and starts it with serve.sh on http://127.0.0.1:5180 (MCP_PORT).
Run from a CMS 13 edge-case site (setup.sh reads the CMS version from its project), it references
src/OptiCli.Mcp.Cms13 instead and adds McpVisualBuilderFixture.cs: Visual Builder experiences with inline and shared
sections and elements, some of them readable by administrators only, and an element type with properties the edit UI
hides or locks. The integration tests take the edit UI's path from the site's CMS version, and their Visual Builder tests
run only there:
SQLCMDPASSWORD=... FRESH=1 MCP_PORT=5200 tests/fixtures/mcp/setup.sh path/to/AlloyEdge13 path/to/AlloyMcp13 alloy-edge13 alloy-mcp13
tests/OptiCli.Mcp.Integration connects to it as Claude does, with the official MCP SDK's client: discovery from the
401, registration, the CMS login form and the consent page (both scripted), then tool calls. It covers sign-in, the
role gate and CIMD; the module's 401 untouched by the site's error pages, and the site's named route working beside
the module; that hidden content is the same not_found as missing content, also when it is named in a reference or
rich text, and that a search doesn't count it; return addresses only on Claude's hosts or loopback, and metadata that
isn't cached; drafts in the editor's name, publish refused without Publish rights, approval sequences, baseVersion
conflicts, uploads, images shown (a large one scaled down by the site's image service), editUrl, previewUrl (a draft shown to the editor signed in, and to nobody else), the assistant's preview links (on the default instance; none, and no preview path, where they are turned off), and resolve_url; review requests without publishing (none where no sequence applies),
content type access rights, restores refused, a branch not published before its master, list_children paging; script
in rich text and links refused, properties the edit UI hides or locks (also by an editor descriptor, or a tab only
administrators see) left out and refused, a language only administrators may edit refused to an editor, live content
not moved below another approval sequence; links as markup refused, what a property already has written back while
anything new is refused, SVG files with script and HTML files not uploaded, locks inside local blocks and block lists;
parallel refreshes, refresh token rotation and reuse within and after the grace period, fewer scopes on a refresh,
revocation on the connections page, and a removed role or a disabled account caught at the next refresh (which changes
the test site's own database and changes it back). McpFixture.cs also adds RestrictedBlock, a block type only
WebAdmins may create; McpFieldsBlock, with a property not shown in edit mode, a read-only one, one an editor
descriptor locks for all but WebAdmins and one on a tab that needs Administer, plus rich text, link, URL, ContentArea
and reference properties, a local block and a block list (McpInnerBlock, with a property locked for all but
WebAdmins and one on the tab that needs Administer); an "opticli write-back fixture" block with a video's <iframe> and
an sms: link; the language de, enabled and editable by administrators only; and a folder "opticli find fixture" in the
global assets with one folder editors see and one only administrators see. setup.sh gives the site high RateLimits,
as the tests sign in many times a minute, and a RefreshTokenReuseGrace of 3 seconds, so the reuse test needn't wait a
minute. The tests are skipped unless the site is configured, and delete the content they create:
SQLCMDPASSWORD=... FRESH=1 tests/fixtures/mcp/setup.sh path/to/AlloyEdge path/to/AlloyMcp
OPTICLI_MCP_IT_URL=http://127.0.0.1:5180 \
OPTICLI_MCP_IT_USERS=path/to/AlloyMcp/App_Data/mcp-test-users.json \
dotnet test tests/OptiCli.Mcp.Integration
The tests for a site with the module's defaults, which allows neither publishing nor deleting (publishing refused,
moves of live content and discards of scheduled versions refused, discarding someone else's draft refused), need a
second instance of the same site, started with OptiCli__Mcp__AllowPublish=false and OptiCli__Mcp__AllowDelete=false
(serve.sh --no-publish), and OPTICLI_MCP_IT_NO_PUBLISH_URL:
tests/fixtures/mcp/serve.sh path/to/AlloyMcp --port 5181 --no-publish
OPTICLI_MCP_IT_NO_PUBLISH_URL=http://127.0.0.1:5181 OPTICLI_MCP_IT_URL=... OPTICLI_MCP_IT_USERS=... dotnet test tests/OptiCli.Mcp.Integration
tests/fixtures/mcp/serve.sh path/to/AlloyMcp --port 5181 --stop
The assistant's preview links are on by default, so the default instance runs their tests (a draft's link opened
with no cookie, refused with another path, a query, another spelling, another host or another method, still the
version after publishing, gone in the recycle bin, after a revocation and once the editor can't read the version, and
the CMS's redirect to its login never passed on). The test for a site that turned them off (no link, no preview path)
needs a third instance, started with OptiCli__Mcp__AllowPreviewLinks=false (serve.sh --no-preview-links), and
OPTICLI_MCP_IT_NO_PREVIEW_LINKS_URL:
tests/fixtures/mcp/serve.sh path/to/AlloyMcp --port 5182 --no-preview-links
OPTICLI_MCP_IT_NO_PREVIEW_LINKS_URL=http://127.0.0.1:5182 OPTICLI_MCP_IT_URL=... OPTICLI_MCP_IT_USERS=... dotnet test tests/OptiCli.Mcp.Integration
tests/fixtures/mcp/serve.sh path/to/AlloyMcp --port 5182 --stop
CI runs the unit tests on Linux, Windows and macOS for every push and pull request. On
Windows it starts SQL Server Express LocalDB (the image's; it installs Microsoft's MSI, checked against a pinned SHA-256,
only when the image lacks it), and LocalDbTests must pass there (OPTICLI_REQUIRE_LOCALDB turns its skip into a
failure).
Issues and pull requests are welcome. Please run the unit tests before sending a change. When a change touches
reads, also run the integration test against a site you have.
Set the new version as <Version> in Directory.Build.props and as opticli-version in
skill/SKILL.md, and add the release to CHANGELOG.md. Commit, then push a matching tag:
git tag v0.23.1 && git push origin v0.23.1
The release workflow checks that the tag matches both versions and is on main,
runs the unit tests, checks that the CLI package holds both site agent builds and starts, and publishes it to nuget.org together
with OptiCli.Mcp and OptiCli.Mcp.Cms13 at the same version.
--no-mediaskipped items, newer drafts left
out, externalRefs (content the plan refers to but doesn't create, with its name and whether it is missing or in
the source's recycle bin: the target must have it, or the dry run fails with not_found), visitorGroups
personalized values show to, dropped values that can't be written to another database (a value of a property the
source's code no longer has, a reference to content that doesn't exist (in a ContentArea only that item), rich text
with an inline block (whose values the CMS keeps apart from the text, and no write takes), an empty CMS 13
composition on a branch other than the master branch, which the CMS can't save safely), references
to a content provider's content (providerRefs, kept as they are), and notExported: access rights, other versions
and who saved them when, and everything that isn't content (Forms submissions, visitor groups, categories, users).modelskipped--strictdetails.reason: "modelDiffers"dropped (in a ContentArea, only
that item); content whose parent isn't there is skipped.--blobs as for export (for a local source, the project's file blob folder by default). A media
item's file is compared with the local site's (EPiServer:Cms:FileBlobProvider:Path, also from the environment);
where that can't be read, the upload goes to the site, which compares the bytes.counts and lists per action (created, updated, moved, restored, trashed, conflicts, kept,
gone, skipped with reasons), dropped, drafts (refs are the source's), model, the media files, the number of
plan steps and run, apply's report with each step's status and undo hints. An empty diff applies nothing:
steps is 0 and run null. Progress goes to stderr on a terminal. --out <dir> keeps the plan the pull applied.details.run lists what was saved with undo hints.local (refused for dev, test and prod, exit 3,
details.reason: "syncTarget"): a shared database is other people's, and in shared mode its database doesn't hold
the build's model. --from is picked like --db (a name from db add, an id or a database name); --connection and
OPTICLI_DB name the local database.sync pull is for keeping a restored copy, or part of one, current afterwards.| CLI (seal, terminal, checks), the run's own site launcher, site agent |
The site started by serve is pinned to the database the CLI reads. It refuses to start against any remote database except the approved development one. There it runs without scheduler, schema updates, content type sync or store remapping, and serve refuses a build with EF Core migrations the database lacks. If the site turns hosting startups off in its code, so the pin can't run, it refuses to start. | CLI + site agent at startup |
The site agent answers only loopback callers that send the per-run token, and only in Development. The token is kept in a state file only your user can read. | Site agent, per request |
Writes create drafts; publishing needs --publish (or publish). Saves are attributed to the user opticli. | CLI + site agent |
A publish that would also put live changes someone else saved after the published version stops: on a terminal it shows who saved what and asks, elsewhere it fails with conflict (exit 5) listing them. --include-draft (a plan step's "includeDraft": true) confirms; publish --version <id> publishes that version as it is; --from published bases the change on the published version, leaving them out. | Site agent |
Against a shared database, writes stop while the build and the database differ (drift): on a terminal it shows the differences and asks, elsewhere it fails with drift (exit 5). --accept-drift <fingerprint> confirms; the fingerprint stops counting when the differences change. | Site agent, CLI first |
Against a shared database, sites primary and sites host remove are refused (exit 3), and sites host add only adds hosts of type undefined: the deployed site uses the same site definitions. There is no override; change those sites in their own admin UI. Site definitions are never reachable from the MCP module. | Site agent, CLI first |
delete moves content to the recycle bin; nothing empties it, and restore brings it back. Site roots, start pages, asset roots and anything above them can't be moved or deleted. The MCP module doesn't restore. | Site agent |
serve turns the site's scheduler off, so a restored database's overdue jobs don't all start (--scheduler leaves it on). jobs run refuses jobs that delete for good (emptying the recycle bin, trimming versions, truncating the change log, Commerce's expired carts and archived items, ...) or move content across the site, and jobs set refuses to let the scheduler run one (sooner), without --allow-destructive. Against a shared database jobs aren't run, stopped or changed, and the scheduler stays off. | Site agent, CLI first |
users add only adds users to the site's ASP.NET Identity, tagged as made by opticli, and users remove only removes those: an existing account's password, address and roles are never touched. A generated password is written to a file only you can read and never printed. No command lists users; users roles counts them per role. Refused against a shared database, and on a site whose CMS users come from elsewhere (OpenID Connect). | Site agent, CLI first |
types remove, types remove-property and types prune only remove what removed code left in the database, as the running site judges it: a content type whose class the site can't load (never one made in admin mode, nor one of the CMS's own) and a property that isn't in its type's code. A type stays while content of it exists, also in the recycle bin (opticli doesn't delete content), while a property has it as its block type, and while page-type values name it. A property's stored values go with it only with --allow-destructive, and never one a content provider uses. The site writes each removal's record to removals.jsonl before it removes it. Only through the site, refused against a shared database, and never reachable from the MCP module. | Site agent, CLI first |
Reads use fixed queries. sql accepts a single SELECT, refuses anything that writes, runs code, reaches another database or reads server-wide views, logs and traces (in sys, only the views that describe the database's own schema), and always runs in a rolled-back transaction. Personal-data tables (form submissions, users) need --include-personal-data. Where another command's fixed query reads the Dynamic Data Store's tables, it reads only one store's rows and only what it shows (the parents the CMS stored for trash, visitor group names), nothing that names a person. | CLI |
Passwords are never printed; doctor redacts connection strings. The exception is opticli env: it prints the per-run token, and with --include-connection the connection string too. | CLI |
ancestors| the content tree |
find --type T [--where Prop=value] [--under] [--status] | items of a type, filtered; --status scheduled (a version waits to be published: publishAt) and expired (published, stop-publish date passed: expiredAt), in the master language unless --lang |
search <text> [--in names|strings|all] | names and text properties containing a string |
where-used <ref> [--pages], where-used --type T | ContentAreas, references, links and rich text pointing at an item (--pages: through nested blocks up to pages); --type: for every instance of a type |
resolve <url>, url <ref> | URL to content, and content to URL per language |
versions <ref>, drafts [--since] [--by] [--kind] [--type] | version history; unpublished changes |
history <ref> [--since] [--by] | the CMS's change log for one item: creates, publishes, moves (from, to), deletes and restores, with who and when; the only record of moves and deletes (drafts saved aren't in it) |
categories, visitor-groups | the category tree (names set takes, selectable, how many items use each); visitor groups (ids ContentAreas store, names, how criteria combine), without their criteria or notes |
projects [<id>] | projects, and the versions in one |
trash [--since] [--by] [--type] | what is in the recycle bin: who deleted it and when, what came along (descendants), and originalParent, where restore puts it back (asked of the site while serve runs, meta.source: agent; otherwise read from the database, with a warning saying why) |
jobs [--failed], jobs log [<job>] [--failed] [--since] | scheduled jobs: schedule, next and last run, how it ended, overdue, running; their runs with status and message (see Scheduled jobs) |
display-templates [--type T] | CMS 13: Visual Builder display templates and their settings, the styles a composition's nodes can use |
blob <ref> | where a media file lives on disk |
export <ref>... [--descendants], export --site <name>|--all | content of any database, production too, as a plan folder that apply --update-existing brings into another one (see Exporting content) |
drift | what differs between the build and a shared database (needs serve; see Shared databases) |
access <ref> | who may read and edit an item: its access rights, and the ancestor they are inherited from |
sql "<SELECT …>" | anything else, read-only |
create --blueprint--variationsites primary and sites host add|remove change site definitions' host names; see
Site hosts after a database restore.jobs run, jobs stop and jobs set run, stop and reschedule scheduled jobs; see Scheduled jobs.users add and users remove make and remove a local login for a restored database; see
Local users.types remove, types remove-property and types prune remove content types and properties that removed code left
in the database; see Orphaned content types.sync pull --from <name> brings another environment's content into the local database, writing only what changed;
see Pulling content from another environment.apply <plan> --dry-run --seal-for <prod> and sync push --to <prod> prepare a write to production and seal it; only
the user runs it, with apply <plan> --db <prod> --seal <id> in their own terminal. See
Writing to production.apply plan.json runs several operations validated together. Later operations can refer to an item an earlier
one created as $id. A property value "@texts/body.html" is that file's text, as Prop=@file is on the command
line. Files in a plan are relative to the plan file and must stay inside its folder. With "guidNamespace", what a
plan creates gets the same GUIDs in every database, and apply --update-existing runs it again: existing content is
updated (and moved back out of the recycle bin; an upload's file replaced when the bytes differ), and steps that are
already done change nothing. A plan that stops
halfway (a failure, Ctrl+C) still reports what it saved, with undo hints and details.partial: true.
A plan leaves one draft per content and language, as the edit UI does: a set, area or composition step on
content and a language that an earlier step of the run saved a draft of (a set, area or composition, or a create,
block, upload or translate that didn't publish) saves into that draft. Such a step says savedInto (the version and
the step that made it), and its undo points to that step's. So a later discard without a version discards the whole
draft, with the changes of every step that saved into it. After a step that publishes, schedules, sends for review,
unpublishes, discards or deletes, the next change starts a new draft (and the undos of the steps that saved into the
draft point to that step's). Steps with from, baseVersion or force, or a ref that names a version, make a new
version, and so does a step whose draft someone else saved or published in the meantime (opticli checks the
database, the site checks again; the step's warning says so). apply --version-per-step saves a version per step,
as earlier versions did; use it on a site whose own code asks for a new version on every save (a saving-event
handler that sets RequireNewVersion), where saving into the draft fails with a conflict that says so.--allow-destructive--allow-destructivedb use chooses another development database, run ide on again and rebuild.
ide status and doctor say when that's needed.runtimeconfig.json is written at build time: after ide on or ide off, rebuild. In Rider (and Visual Studio)
use Rebuild: the play button's up-to-date check doesn't notice a changed .csproj.user and starts the old build.
dotnet build and dotnet run do notice it.ide off, rebuild, and seal again.srcdocdata:Optimizely.Cms.Opal.Tools) can run in the same site: see
Beside another MCP server.PreviewLinkLifetime| 10 minutes |
| how long such a link works (1 minute to 1 hour) |
MaxUploadBytes | 10 MB | the largest media file an assistant may upload (at most 50 MB); the CMS UI's own upload limit applies too, if it is lower (see below) |
RateLimits | see below | requests a minute the OAuth endpoints take |
<iframe><object><embed><form><base><meta><link><svg>{href, text, title, target}styleclass<iframe><textarea><style><template><svg>"EPiServer": {"Cms": {"ScriptParser": {"SavingMode": "Remove"}}}.html, .htm,
.xhtml, .xml, .xsl, .js and the like, but also the legacy .hxt, .stm, .xsd, .dtd or .wsdl a default
site serves as HTML or XML. Nor compressed SVG (.svgz). The CMS serves media by a URL made from its name, so a
media name (on upload, a rename, a replaced file) that ends in another extension than the file's is checked as that
extension. The hint says to upload such a file in the CMS. An SVG file (or a file the site serves as SVG) is read: one with
<script>, <foreignObject>, event handler attributes, javascript: or non-image data: links (also set by an
animation), entity or attribute list declarations or an xml-stylesheet instruction is refused, and an ordinary
drawing uploads. A file named as a raster image (.jpg, .png, ...) that starts like markup is refused too. Have the
site send X-Content-Type-Options: nosniff with its media, so a browser never takes a file for anything but what its
content type says.AllowedRoles) is checked against the editor's roles as they are now (the CMS UI's role
provider), not the ones in their login cookie: at consent and at every token refresh. So is their account, where the
site manages it: an ASP.NET Identity user who is disabled, locked out or deleted gets no consent and no new tokens.
An editor who fails either at a refresh gets no new tokens, and the connection is deleted; the access token they
have works until it expires (AccessTokenLifetime).ConnectionLifetime bounds that: counted from when the editor allowed the connection, checked at every refresh and
on every access token, after which they have to connect again through the site's login, where the identity provider
has its say. The default is 30 days; for a site with external logins, 1 to 7 days is a better choice. The site's
login cookie counts as signed in while it lasts, so its lifetime matters too.requestApproval, which sends it for review as the edit UI's Ready for Review does.
requestApproval never publishes, so it needs neither AllowPublish nor content:publish: where no sequence
applies, it is refused (noApprovalSequence) and nothing is saved.AllowPublish, AllowDelete). Without AllowPublish the consent page
doesn't offer the content:publish scope, and every publish is refused with a hint to save a draft instead; with it,
the editor still chooses on the consent page, and their Publish rights still apply. Besides each tool's own check,
every save that would publish or schedule is checked against the same gate, and so is every other change that puts
something live at once: moving content that has a published version or anything below it (its URLs change, it takes
the access rights of its new parent; the CMS itself only asks for Publish on the destination when the moved item is
published), discarding a version scheduled for publishing, and saving content without versions. Content that may be
live isn't moved from one approval sequence to another at all (move it in the CMS). Changing access rights and
removing language branches aren't offered at all.AllowDelete alone (and the editor's Delete rights): delete_content needs no publishing rights. Discarding a draft
someone else saved deletes their work for good, so it needs AllowDelete too; an editor's own drafts can always be
discarded.includeDraft is a confirmation, not a boundary. It stops the assistant from putting live or deleting someone
else's changes without asking the user, but the assistant sets it itself; what the site allows is decided by
AllowPublish, AllowDelete and the editor's access rights.RefreshTokenReuseGrace of the refresh that
replaced it, which is a client refreshing twice at once or retrying after a lost answer: that is only refused, and
the connection keeps the tokens the other refresh got. Older refresh tokens are just refused. A refresh may ask for fewer scopes: the access token
gets those, and the connection keeps what the editor approved. Tokens are bound to the MCP endpoint and checked
against their connection on every use, so revoking one cuts the client off at once (another instance of a
load-balanced site notices within 30 seconds). Codes, secrets and refresh tokens are stored as hashes only. Redirect
URIs must match exactly (any port on loopback), and be on a loopback address or one of AllowedRedirectHosts. The
consent page can't be framed and checks an antiforgery token. Client metadata documents are only fetched from public
addresses, checked on the connected socket, without redirects. Registration, authorization and token requests are
rate-limited (RateLimits). Registered clients without a connection are deleted a day after they registered, and
registration stops at 5,000 of them; the cleanup runs in the background, a bounded amount at a time.parent id, which they can't open), and that a ContentArea or content reference they can read points to something
(its id, which they can't open). Both are by design: a bare id, never the content's name, type or values.
list_children's cursor counts only what the editor can read, so paging tells nothing of what lies between, and
find_content counts only what they can read too; it stops after loading 20,000 items in all, hidden ones included
(or after 20 seconds), and then says truncated: a search below a great deal of hidden content can still tell that
much.MaxUploadBytes: the CMS UI's size limit
(EPiServer:CmsUI:Upload:FileSizeLimit, the lower of the two applies) and, from CMS UI 12.33, its list of allowed
file extensions (AllowedFileExtensions), so a site that keeps SVG out of its uploads keeps it out here too. On CMS
13 these are the CMS's MediaFileOptions, and the CMS's script parser applies too (see On CMS 13).view_media shows the assistant a media item's image, with the editor's read access like every read.
JPEG, PNG, GIF and WebP files up to 1568 pixels on the longest side and 1 MB are sent as they are stored. Larger ones
are scaled down with the site's own image service, the one that makes the edit UI's thumbnails (built into CMS 12
before 12.17, then EPiServer.ImageLibrary.ImageSharp, which EPiServer.CMS brings, on CMS 13 too). An image of
more than 64 million pixels isn't decoded, and other files (PDF and the like) aren't shown. SVG is sent as its markup. Text in an
image is content like any other, with the same risk of prompt injection.Cache-Control: no-store). Still, limit which hosts the site
answers on: set RequireHost (or the site's AllowedHosts), and behind a proxy accept forwarded headers from the
known proxies only.editUrl, the version in the
CMS edit UI, previewUrl, the version as visitors will see it, and url, its public address). A dry run can't run the site's own code that reacts to saves; the real
save reports what it did as siteChanges (content it created, published or moved, or a new page it filed elsewhere). Advise editors not to use this connector in the same chat as tools that fetch web pages or read other
outside content: what such a page says is then one step from the CMS.previewUrl, on a write result that saved a version visitors don't see
yet and on get_content of such a version, is the CMS's own preview of it (/EPiServer/CMS/Content/...,,123_456/,
under /Optimizely/CMS/ on CMS 13),
on the edit link's host: it opens for someone signed in to the CMS with access to the draft, and sends anyone else to
the login. It carries no preview token, so the assistant can't open it itself, and a link in a chat transcript shows
nobody the draft. The CMS can add one (CMS 12.21 and later, UrlResolverArguments.AppendPreviewToken), but such a
token isn't a link to one draft: it signs whoever holds it in as the editor, for five minutes, and from CMS 12.22 (and
on CMS 13) on any request to the site, not only the content it was made for. The module never makes one.AllowPreviewLinks; false turns them off). The page
renders as the editor, so what an editor gets and visitors don't (navigation, blocks, user-specific output) reaches
whoever holds the link for its lifetime, and the site's own code for that page runs in the editor's name: a site
that doesn't want that turns the option off. With it on, the same results also have assistantPreviewUrl (and
assistantPreviewExpires): {origin}{BasePath}/preview/{payload}, on the host the editor connected the assistant
to, which the assistant fetches itself to check how the draft looks. The payload
names the connection, the editor, the version, its language and the host the link was issued on, signed and
time-limited with the site's Data Protection keys; it carries no CMS preview token and nothing that works anywhere
else. The module renders the CMS's preview of that one version inside the site, as the editor, for that one request:
it answers GET and HEAD only, on the link's own host only, and takes the version from the payload alone. Only the
link exactly as the module gave it opens: that path in its casing, one segment of base64url (no other spelling of
the same bytes, no padding) and no query; anything else below {BasePath}/preview is refused. It drops the request's
body and every header but Host, Accept, Accept-Encoding, Accept-Language, User-Agent and the forwarded
headers (so its cookies and Authorization too), and sends no cookie, no redirect, no sign-in challenge and no error
page back (anything but a success is a plain 404, or 500 for an error or any failure), with Cache-Control: no-store, X-Robots-Tag: noindex, nofollow and Referrer-Policy: no-referrer. The module itself changes nothing,
but the site's own code for that page runs as the editor, as when the editor previews the draft in the CMS: code that
writes something when a page is viewed does so in the editor's name.
The link stops working after PreviewLinkLifetime (10 minutes), when the connection is revoked or ends, and when the
editor can no longer read the version (with the roles of the connection's last refresh, as for every tool call).
Until then, whoever has it sees that draft: the assistant, and anything that keeps the chat or the fetch, such as a
transcript or a proxy's log. They see it as the editor sees it: a page type whose view shows something for the
signed-in user (their account, content filtered by their access, a visitor group by role) shows the editor's. That
includes the editor-only parts a site's layout adds for a signed-in editor, such as the CMS's quick navigator (with
links to the dashboard and the edit UI), the edit UI's communication script, a "Log out" link, or an antiforgery
token bound to the editor in a form. None of these works for anyone else (no cookie comes with the link, and none is
set), but they are in the HTML. So the server's instructions tell the assistant not to hand the link to the user, who
reviews the draft through editUrl and previewUrl. Each opened link is audited (the version, the editor, the
client and the outcome; never the link), and rate limited per connection (RateLimits.PreviewPerMinute); requests
that aren't a valid link have a window of their own per address (RateLimits.InvalidPreviewPerAddressPerMinute), so
that a flood of them from an assistant provider's shared addresses can't use up anyone's.
The module adds a middleware at the start of the site's pipeline for it (only with the option on), since a preview
needs routing and the site's own views after it. The rate limit, RequireHost and the link's host need the client's
address and host behind a proxy, so the forwarded headers must be applied before the module checks them. Where they
already are (a startup filter before the module: AddCmsCloudPlatformSupport() on DXP, or
ASPNETCORE_FORWARDEDHEADERS_ENABLED), the module leaves them as they are. Where the site applies them only in its
own Configure, after the module, the module applies the site's settings (ForwardedHeadersOptions, with their
KnownProxies and KnownNetworks) to these requests itself, and the site's own UseForwardedHeaders() then finds
nothing more to do. That needs the settings in the services (services.Configure<ForwardedHeadersOptions>(...)), not
only passed to UseForwardedHeaders(options) in Configure, which the module can't see: there, behind a proxy that
changes the host, links answer 404. The module also leaves them alone when a proxy sends an X-Original-Host (or
-For, -Proto) header of its own, as if they had been applied already: behind such a proxy, apply them before the
module (ASPNETCORE_FORWARDEDHEADERS_ENABLED or a startup filter). Call AddOptiCliMcp() after AddCms() (as
above), so the module comes after the CMS's first-request initialization, and after AddCmsCloudPlatformSupport() on
DXP, so it comes after its forwarded headers. A base path the site sets with UsePathBase() isn't supported for these
links (an IIS application's is).serveserve --schedulersetup.sh--update-existingFRESH=1