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

Copilot plugin

The assistant in the admin — off until an operator says otherwise.

Documents 0.13.1 Updated Edit this page Report a problem

On this page

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…
BackendPackageRegistered when
Claude@orthacms/copilot-provider-anthropicANTHROPIC_API_KEY is set
OpenAI-compatible@orthacms/copilot-provider-openaiCOPILOT_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=
VariableDefaultNotes
COPILOT_ENABLEDfalsefalse unregisters every /api/copilot route (404)
COPILOT_MAX_OUTPUT_TOKENS8192Must be positive
COPILOT_MAX_STEPSplugin default 30Model calls in one run
COPILOT_WALL_CLOCK_MSplugin default 300000Wall clock for one run
COPILOT_MAX_TOTAL_TOKENSplugin default 400000Input + output tokens across one run
COPILOT_USER_DAILY_TOKENSno limitTokens one user may spend in one workspace per day (UTC)
COPILOT_WORKSPACE_DAILY_TOKENSno limitTokens one workspace may spend per day (UTC)
COPILOT_RUNS_PER_MINUTEno limitRuns one user may start per minute
COPILOT_MAX_CONCURRENT_RUNSno limitRuns 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 pages

CopilotPluginConfig 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=true with 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_ENABLED is not true.
  • { "items": [] } is impossible while enabled: the server would not have booted.

Start a run. The response is text/event-stream:

Example language
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+J starts a new chat. The launcher, ⌘J and 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 declares takeover on workspaces-admin’s WorkspaceSectionItem to 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.

RouteWhat it does
GET /modelsProvider × model catalogue, in registration order
POST /runsStarts a run (SSE). Needs X-Workspace-Id. A 429 when a usage limit refuses it
POST /runs/:runId/permissionAnswers a permission prompt
GET /conversations, GET /conversations/:idTranscript
PATCH /conversations/:idtitle, archived, modelChoice
GET /proposals, GET /proposals/:idRecord of applied changes
GET /skillsSkills available to a run
GET /suggestionsThe new chat’s openers (since 0.13.0). Needs X-Workspace-Id
GET /usageThe 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.