The copilot — Ortha CMS AI in the admin — is off by default, because a hosted backend sends workspace content to a third party.
Turn it on
COPILOT_ENABLED=true
# At least one backend. Setting its key / URL is what registers it.
ANTHROPIC_API_KEY=sk-ant-…
# …or any OpenAI-wire endpoint (Ollama, vLLM, LiteLLM, OpenAI, Azure…)
COPILOT_OPENAI_BASE_URL=http://localhost:11434/v1
COPILOT_OPENAI_MODELS=<model-id>
COPILOT_OPENAI_API_KEY=- Once on, the Ask Ortha AI launcher appears under the admin’s work area,
at its right end, inside any workspace (
⌘J/Ctrl+J), for users withcopilot:use. COPILOT_ENABLED=truewith no backend registered fails at boot.- Off means absent: no
/api/copilotroute, no chat, no Agents view, no Skills page. The MCP endpoint is unaffected — the tool registry stays bound. - Every variable per backend: model providers.
There is no “which provider” setting
- A backend is registered only when configured, in the order
plugins.tslists them. - The first registered one serves a run that names none, and is what the model picker opens on.
- There is no
defaultProvidervariable — a name in a variable could be misspelt or point at nothing.
Registered names: claude and openai in a generated app; claude and
ollama in the monorepo’s own app.
Check it from the command line
The copilot routes are session-authenticated admin routes. Sign in first:
curl -c cookies.txt -X POST https://cms.example.com/api/auth/login \
-H 'Content-Type: application/json' \
-d '{"email":"editor@example.com","password":"…"}'Which backends are live (copilot:use):
curl -b cookies.txt https://cms.example.com/api/copilot/models{
"items": [
{ "provider": "claude", "model": "<model-id>" },
{ "provider": "openai", "model": "<model-id>" }
]
}A 404 here means COPILOT_ENABLED is off.
Start a run — the answer streams back as Server-Sent Events:
curl -N -b cookies.txt -X POST https://cms.example.com/api/copilot/runs \
-H 'X-Workspace-Id: 7f1c…' \
-H 'Content-Type: application/json' \
-d '{"message":"Which articles are missing a summary?","skills":[{"name":"house-style"}]}'event: run-started
data: {"type":"run-started","conversationId":"…","runId":"…","messageId":"…"}
event: tool-call
data: {"type":"tool-call","id":"call_1","name":"admin_content_search","input":{"typeName":"article"}}
event: tool-result
data: {"type":"tool-result","id":"call_1","name":"admin_content_search","ok":true,"durationMs":41,"summary":"…"}
event: text-delta
data: {"type":"text-delta","text":"Three articles have no summary: …"}
event: done
data: {"type":"done","stopReason":"end","usage":{"inputTokens":5120,"outputTokens":312}}| Body field | Meaning |
|---|---|
message | Required. The user’s turn |
conversationId | Continue an existing conversation |
provider, model | Pick a backend explicitly; wins over the default and any resolver |
skills | Up to 3 { "name": … } to attach for this turn — see skills |
uiLocale | The admin locale, so the model answers in that language; default en |
Other events: tool-permission-request (a write is waiting for Allow/Don’t
allow), proposal (a recorded change), error. The route requires
copilot:use, workspace membership and, as a cookie write, an allowed Origin
or none.
The run ceilings
Three limits bound a run; a fourth bounds each model response.
| Variable | Config key | Default | Bounds |
|---|---|---|---|
COPILOT_MAX_STEPS | limits.maxSteps | 30 | Model calls in one run |
COPILOT_WALL_CLOCK_MS | limits.wallClockMs | 300000 | Wall clock for the whole run |
COPILOT_MAX_TOTAL_TOKENS | limits.maxTotalTokens | 400000 | Uncached input + output + cache writes, across the run |
COPILOT_MAX_OUTPUT_TOKENS | maxOutputTokens | 8192 | One model response |
- Each must be a positive number;
0or negative fails at boot. - Cache reads don’t count. The token ceiling is checked between steps, so a run can overshoot by one call.
- A step is one model call plus the tools it asks for —
maxStepsbounds depth, not width. Small local models need more steps. - A run that stops reports
stopReason:max-steps,timeout,max-tokens,max-output-tokens, orquota(see usage limits). The chat shows “This answer is incomplete”.
Move the three ceilings together
They are checked in the same loop. Raising only maxSteps just makes the run
stop on “took too long” instead. Raising them costs tokens, not safety — every
step is still authorised and audited.
A generated app reads only two of these from the environment
An app from create-orthacms-app reads COPILOT_ENABLED and
COPILOT_MAX_OUTPUT_TOKENS. Set the other three in code, in
apps/server/config/copilot.ts:
export function copilotConfig(): AppCopilotConfig {
return {
enabled: readFlag('COPILOT_ENABLED', false),
maxOutputTokens: readPositiveInt('COPILOT_MAX_OUTPUT_TOKENS', 8192),
limits: { maxSteps: 60, wallClockMs: 600_000, maxTotalTokens: 800_000 },
providers: defined({ /* … */ })
};
}The monorepo’s app reads all four variables.
Usage limits
The ceilings bound how big one run may get. Four more limits (since 0.11.0) bound how much the copilot may be used. Each is off until you set it.
| Variable | Config key | Bounds |
|---|---|---|
COPILOT_USER_DAILY_TOKENS | quotas.userDailyTokens | Tokens one user may spend in one workspace per day (UTC) |
COPILOT_WORKSPACE_DAILY_TOKENS | quotas.workspaceDailyTokens | Tokens all members of one workspace may spend together per day (UTC) |
COPILOT_RUNS_PER_MINUTE | quotas.runsPerMinute | Runs one user may start per minute, across workspaces |
COPILOT_MAX_CONCURRENT_RUNS | quotas.maxConcurrentRuns | Runs one user may have in flight at once, across workspaces |
- Empty is no limit;
0or a negative number fails at boot. - Tokens are counted the way
COPILOT_MAX_TOTAL_TOKENScounts them. - Spend is recorded either way, in the
copilot_usagetable — so a quota switched on at noon already knows the morning. The counts live in Postgres, so they hold across instances and survive a restart. - A run is checked before it starts. A refusal is a
429and nothing is stored — no thread, no message (the codes are in errors). - The daily quotas are checked again between steps. A run that spends what
is left stops with
stopReason: "quota"and keeps its partial answer, and the model is told so a later “continue” resumes it. Like the token ceiling, it can overshoot by one model call. - A daily quota below one run’s token ceiling is allowed, with a warning at boot: a long run will be stopped by the quota before its own limit.
In the admin, when a person has a daily quota, a thin meter under the chat box
shows their use today. Once it is spent — theirs or the workspace’s — the box
refuses to send, keeps the text typed, and says when sending works again, in the
reader’s own clock. A member sees their own figure; of the workspace’s quota
they see only whether it is spent (GET /api/copilot/usage).
A generated app reads none of these yet
Like the three ceilings, the four limits are read from the environment by the
monorepo’s app. In an app from create-orthacms-app, set quotas in
apps/server/config/copilot.ts:
quotas: { userDailyTokens: 2_000_000, runsPerMinute: 10, maxConcurrentRuns: 2 },Where the settings live
| File | Holds |
|---|---|
apps/server/config/copilot.ts | Kill switch, run ceilings, usage limits, the providers map |
apps/server/config/copilot-anthropic.ts, copilot-openai.ts | Per-backend env reading (generated app) |
apps/server/orthacms.config.ts | Assembles everything into one config object |
apps/server/src/plugins.ts | copilotProviders(config) and CopilotPlugin({ … }) |
Provider settings live in the app, not the copilot packages — the plugin is
adapter-agnostic. Two backends of one kind are two entries (ollamaFast,
ollamaBig).
Per-run routing
The first registered provider serves any run that names none — in practice MCP-initiated work, API calls and tests, since the admin’s picker always sends its choice. To route per run, pass a resolver:
// apps/server/src/plugins.ts
CopilotPlugin({
providers: copilotProviders(config),
// (ctx: { workspaceId, userId }, providers) => a registered provider name
resolve: (ctx) => (bigWorkspaces.has(ctx.workspaceId) ? 'claude' : 'openai'),
config: config.plugins.copilot
});A model the user picked explicitly still wins.
Who can use it
copilot:use— held by every role (Admin, Contributor, Viewer).- Beyond that a run holds the user’s own permissions. There are no separate copilot grants and no per-workspace copilot policy.
- Skills are the only per-workspace setting — authored by Admins
(
copilot:skills:manage). See skills.
Run it locally with no API key
There is no scripted “offline” backend — the fake provider is a private test fixture. Run a local model through the OpenAI-wire adapter instead:
ollama serve
ollama pull <model-id>COPILOT_ENABLED=true
COPILOT_OPENAI_BASE_URL=http://localhost:11434/v1
COPILOT_OPENAI_MODELS=<model-id>
COPILOT_OPENAI_API_KEY=That exercises the chat, the permission prompt and the change cards end to end. The adapter assumes tool calling, streaming and a 32K context — declare less for a small model; see model providers.