Beta . Ortha CMS is in public beta. Every plan is free until it ends.
Features Ortha CMS on GitHub

Setting up the copilot

Copilot plugin @orthacms/copilot-server@orthacms/copilot-admin

The kill switch, the backends, the run ceilings, the usage limits, and how to check it all works.

Documents 0.13.1 Updated Edit this page Report a problem

On this page

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 with copilot:use.
  • COPILOT_ENABLED=true with no backend registered fails at boot.
  • Off means absent: no /api/copilot route, 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.ts lists them.
  • The first registered one serves a run that names none, and is what the model picker opens on.
  • There is no defaultProvider variable — 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 fieldMeaning
messageRequired. The user’s turn
conversationIdContinue an existing conversation
provider, modelPick a backend explicitly; wins over the default and any resolver
skillsUp to 3 { "name": … } to attach for this turn — see skills
uiLocaleThe 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.

VariableConfig keyDefaultBounds
COPILOT_MAX_STEPSlimits.maxSteps30Model calls in one run
COPILOT_WALL_CLOCK_MSlimits.wallClockMs300000Wall clock for the whole run
COPILOT_MAX_TOTAL_TOKENSlimits.maxTotalTokens400000Uncached input + output + cache writes, across the run
COPILOT_MAX_OUTPUT_TOKENSmaxOutputTokens8192One model response
  • Each must be a positive number; 0 or 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 — maxSteps bounds depth, not width. Small local models need more steps.
  • A run that stops reports stopReason: max-steps, timeout, max-tokens, max-output-tokens, or quota (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.

VariableConfig keyBounds
COPILOT_USER_DAILY_TOKENSquotas.userDailyTokensTokens one user may spend in one workspace per day (UTC)
COPILOT_WORKSPACE_DAILY_TOKENSquotas.workspaceDailyTokensTokens all members of one workspace may spend together per day (UTC)
COPILOT_RUNS_PER_MINUTEquotas.runsPerMinuteRuns one user may start per minute, across workspaces
COPILOT_MAX_CONCURRENT_RUNSquotas.maxConcurrentRunsRuns one user may have in flight at once, across workspaces
  • Empty is no limit; 0 or a negative number fails at boot.
  • Tokens are counted the way COPILOT_MAX_TOTAL_TOKENS counts them.
  • Spend is recorded either way, in the copilot_usage table — 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 429 and 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

FileHolds
apps/server/config/copilot.tsKill switch, run ceilings, usage limits, the providers map
apps/server/config/copilot-anthropic.ts, copilot-openai.tsPer-backend env reading (generated app)
apps/server/orthacms.config.tsAssembles everything into one config object
apps/server/src/plugins.tscopilotProviders(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.