The in-product assistant, shown as Ortha CMS AI in the admin. Packages,
routes and permission keys use the code name copilot.
- A chat panel and a full-page Agents view in the admin.
- A bounded run loop on the server, streamed as Server-Sent Events.
- The model backends the composition root registers. None ship by default.
Install
npm install @orthacms/copilot-server @orthacms/copilot-admin
# plus at least one backend
npm install @orthacms/copilot-provider-anthropic # Claude
npm install @orthacms/copilot-provider-openai # Ollama, vLLM, LiteLLM, Azure, OpenAI…| Backend | Package | Registered when |
|---|---|---|
| Claude | @orthacms/copilot-provider-anthropic | ANTHROPIC_API_KEY is set |
| OpenAI-compatible | @orthacms/copilot-provider-openai | COPILOT_OPENAI_BASE_URL is set |
| Your own | — | you add it to the list |
Configure
# .env
# kill switch, default false
COPILOT_ENABLED=true
# ceiling on one response
COPILOT_MAX_OUTPUT_TOKENS=8192
# Claude — setting the key registers the backend
ANTHROPIC_API_KEY=sk-ant-...
# comma-separated, first is the default
COPILOT_ANTHROPIC_MODELS=claude-sonnet-5
# OpenAI-wire endpoint — setting the URL registers the backend
COPILOT_OPENAI_BASE_URL=http://localhost:11434/v1
COPILOT_OPENAI_MODELS=llama3.1
# empty for a local runtime
COPILOT_OPENAI_API_KEY=
# Run ceilings — reference server only (see below)
COPILOT_MAX_STEPS=30
COPILOT_WALL_CLOCK_MS=300000
COPILOT_MAX_TOTAL_TOKENS=400000
# Usage limits — reference server only, each off when empty
COPILOT_USER_DAILY_TOKENS=
COPILOT_WORKSPACE_DAILY_TOKENS=
COPILOT_RUNS_PER_MINUTE=
COPILOT_MAX_CONCURRENT_RUNS=| Variable | Default | Notes |
|---|---|---|
COPILOT_ENABLED | false | false unregisters every /api/copilot route (404) |
COPILOT_MAX_OUTPUT_TOKENS | 8192 | Must be positive |
COPILOT_MAX_STEPS | plugin default 30 | Model calls in one run |
COPILOT_WALL_CLOCK_MS | plugin default 300000 | Wall clock for one run |
COPILOT_MAX_TOTAL_TOKENS | plugin default 400000 | Input + output tokens across one run |
COPILOT_USER_DAILY_TOKENS | no limit | Tokens one user may spend in one workspace per day (UTC) |
COPILOT_WORKSPACE_DAILY_TOKENS | no limit | Tokens one workspace may spend per day (UTC) |
COPILOT_RUNS_PER_MINUTE | no limit | Runs one user may start per minute |
COPILOT_MAX_CONCURRENT_RUNS | no limit | Runs one user may have in flight at once |
Provider variables are on each backend’s page. A generated app’s
config/copilot.ts reads COPILOT_ENABLED and COPILOT_MAX_OUTPUT_TOKENS
only; the three ceilings and the four usage limits are read by the reference
server. Copy copilotLimits() and copilotQuotas() below into a generated app
to get them. A usage limit set to 0 fails at boot — empty is what off looks
like. See usage limits.
The reference server’s apps/server/config/copilot.ts, trimmed:
import type { CopilotPluginConfig } from '@orthacms/copilot-server';
import type { AnthropicProviderConfig } from '@orthacms/copilot-provider-anthropic';
import type { OpenAiProviderConfig } from '@orthacms/copilot-provider-openai';
import {
defined, readEnv, readFlag, readList,
readOptionalPositiveInt, readPositiveInt, when
} from '@orthacms/utils-server';
export interface OrthaCmsCopilotConfig extends CopilotPluginConfig {
providers: { claude?: AnthropicProviderConfig; ollama?: OpenAiProviderConfig };
}
export function copilotConfig(): OrthaCmsCopilotConfig {
return defined({
enabled: readFlag('COPILOT_ENABLED', false),
maxOutputTokens: readPositiveInt('COPILOT_MAX_OUTPUT_TOKENS', 8_192),
limits: copilotLimits(),
quotas: copilotQuotas(),
providers: copilotProviders()
});
}
function copilotLimits(): CopilotPluginConfig['limits'] | undefined {
// Unset keys are dropped: an explicit undefined would erase the plugin default.
const limits = defined({
maxSteps: readOptionalPositiveInt('COPILOT_MAX_STEPS'),
wallClockMs: readOptionalPositiveInt('COPILOT_WALL_CLOCK_MS'),
maxTotalTokens: readOptionalPositiveInt('COPILOT_MAX_TOTAL_TOKENS')
});
return when(Object.keys(limits).length > 0, () => limits);
}
function copilotQuotas(): CopilotPluginConfig['quotas'] | undefined {
// No defaults behind these: an absent quota is no quota.
const quotas = defined({
userDailyTokens: readOptionalPositiveInt('COPILOT_USER_DAILY_TOKENS'),
workspaceDailyTokens: readOptionalPositiveInt('COPILOT_WORKSPACE_DAILY_TOKENS'),
runsPerMinute: readOptionalPositiveInt('COPILOT_RUNS_PER_MINUTE'),
maxConcurrentRuns: readOptionalPositiveInt('COPILOT_MAX_CONCURRENT_RUNS')
});
return when(Object.keys(quotas).length > 0, () => quotas);
}
// copilotProviders(): see the Claude and OpenAI-compatible pagesCopilotPluginConfig is { enabled, maxOutputTokens, limits?, permissionDecisionBudgetMs?, quotas? }.
quotas (since 0.11.0) takes userDailyTokens, workspaceDailyTokens,
runsPerMinute and maxConcurrentRuns, each optional; an absent one is no
limit, and spend is recorded in copilot_usage either way.
permissionDecisionBudgetMs (default five minutes) bounds how long a run waits
for answers to its permission prompts.
Off by default, deliberately
A hosted backend receives workspace content. COPILOT_ENABLED stays false
until an operator turns it on, even when a key is present.
Register
// apps/server/src/plugins.ts
import { CopilotPlugin, type ProviderRegistration } from '@orthacms/copilot-server';
import { createAnthropicProvider } from '@orthacms/copilot-provider-anthropic';
import { createOpenAiProvider } from '@orthacms/copilot-provider-openai';
export function copilotProviders(config: OrthaCmsConfig): ProviderRegistration[] {
const { claude, ollama } = config.plugins.copilot.providers;
return [
...(claude ? [{ name: 'claude', provider: createAnthropicProvider(claude) }] : []),
...(ollama ? [{ name: 'ollama', provider: createOpenAiProvider(ollama) }] : [])
];
}
// after WorkspacesPlugin and IdentityPlugin
CopilotPlugin({
providers: copilotProviders(config),
config: config.plugins.copilot
// skills: [...] instruction packets defined in code
// resolve: (ctx, registry) => 'claude' route runs that name no provider
});// apps/admin/src/plugins.ts
import { CopilotPlugin } from '@orthacms/copilot-admin';
CopilotPlugin();Rules the plugin enforces at construction:
- The order is the default. The first registration’s first model serves a
run that names none. There is no
defaultProvider. - Register only what is configured. A keyless backend at the top of the list becomes the default and fails on the first message.
- Empty is legal only while disabled.
COPILOT_ENABLED=truewith no provider refuses to boot. - Names must be unique and non-empty. Each provider must declare at least one model.
A generated app registers the OpenAI-compatible backend as openai; the
reference server calls it ollama.
Verify
# 1. Sign in (session cookie)
curl -s -c cookies.txt http://localhost:3000/api/auth/login \
-H 'Content-Type: application/json' \
-d '{"email":"admin@example.com","password":"change-me"}'
# 2. What the model picker offers
curl -s -b cookies.txt http://localhost:3000/api/copilot/models{
"items": [
{ "provider": "claude", "model": "claude-sonnet-5" },
{ "provider": "ollama", "model": "llama3.1" }
]
}404—COPILOT_ENABLEDis nottrue.{ "items": [] }is impossible while enabled: the server would not have booted.
Start a run. The response is text/event-stream:
curl -N -b cookies.txt http://localhost:3000/api/copilot/runs \
-H 'Content-Type: application/json' \
-H 'X-Workspace-Id: 3f6c9a8e-0000-4000-8000-000000000001' \
-d '{"message":"Which content types do I have?","provider":"claude","model":"claude-sonnet-5"}' event: run-started
data: {"type":"run-started","conversationId":"…","runId":"…","messageId":"…"}
event: text-delta
data: {"type":"text-delta","text":"You have three content types: "}
event: done
data: {"type":"done","stopReason":"end","usage":{"inputTokens":2310,"outputTokens":48}} provider and model are optional. A model the provider does not offer ends
the run with an event: error frame naming the models that exist, then
done with stopReason: "error".
In the admin (0.9.0)
- The launcher is Ask Ortha AI, a small button in the strip under the work area’s card, at its right end. It reserves no space in the page, and since 0.13.0 it always starts a new chat.
- A collapsed chat waits as a pill beside it that says what it is doing — ”… — waiting for you” with an amber dot, ”… — finished”, ”… — working…” — and restores on click. Past three the rest fold into “N more chats”. A chat on screen has its window and no pill.
⌘J/Ctrl+Jstarts a new chat. The launcher,⌘Jand a window’s + attach the page you are on as a chip at the top of the window (“Blog posts · this list”), removable with its ×.- When a person has a daily quota, a meter under the chat box shows today’s use; once a quota is spent the box refuses to send and keeps the text.
- Hidden outside a workspace, on the Agents view, for users without
copilot:use, and when the copilot is disabled. - On the Agents view (
/workspaces/:id/agents) the chats are the workspace sidebar: Chats, New chat, a search, the day groups, and Tools (Skills, Archived) take the content tree’s place until you switch back to CMS. The view’s section declarestakeoveron workspaces-admin’sWorkspaceSectionItemto do it. - A new chat opens on four openers written for the workspace, from
GET /api/copilot/suggestions: structure only ({ kind, type?, skill? }), worded by the admin so they translate. A write opener names only a type the workspace owns; a viewer is offered read-only ones. - Skills are a table — Skill · Identifier · Applies · Available — with Available as a switch on the row; New skill and Edit… open a sheet over the list. A code skill says From code and cannot be switched or edited.
Routes
All under /api/copilot, session-authenticated. Each route below needs
copilot:use.
| Route | What it does |
|---|---|
GET /models | Provider × model catalogue, in registration order |
POST /runs | Starts a run (SSE). Needs X-Workspace-Id. A 429 when a usage limit refuses it |
POST /runs/:runId/permission | Answers a permission prompt |
GET /conversations, GET /conversations/:id | Transcript |
PATCH /conversations/:id | title, archived, modelChoice |
GET /proposals, GET /proposals/:id | Record of applied changes |
GET /skills | Skills available to a run |
GET /suggestions | The new chat’s openers (since 0.13.0). Needs X-Workspace-Id |
GET /usage | The caller’s spend today in this workspace, their quota, and whether the workspace’s quota is spent |
Full list: endpoint reference.
Authority
- A run executes as the calling user, offered only the tools that user’s permissions allow. Each call is re-checked when it runs.
- Reads run without asking. A write asks first — Allow once, Allow for this chat or Don’t allow. There is no “always”.
- Since 0.13.0 the prompt says what will happen in words (“Ortha CMS AI wants to save 23 records as drafts”) and, for a previewable tool, shows the change it would make as a Not saved card — “N changes”, Was → Now per record — with the arguments one click away under What it will send. Allowing runs the tool again; the receipt is what happened.
- An allowed change is written to
copilot_proposals, then applied through the same use case a person’s edit uses: validated, audited, versioned. - No tool publishes or approves.
- Conversations can be archived, not deleted.
- Workspace skills are managed with
copilot:skills:manage(Admins). A code skill wins a name collision.
See the copilot for the run loop and agent tools to contribute a tool.
No offline adapter
The scripted backend is a private test fixture. A deployment with no key has no copilot. To run without a hosted key, point the OpenAI-compatible adapter at a local Ollama or vLLM.