Downstream Customization¶
Open Cowork is designed to be repackaged as an internal build without forking the code. Branding, providers, agents, skills, MCPs, and bundled prompts are all expressed in files the app reads at runtime, and a small set of environment variables lets a downstream distribution point the app at its own copy of those files.
This page is the reference for that model.
Mental model¶
Everything downstream-customizable goes through two layers:
- Configuration — a JSON file validated against
open-cowork.config.schema.json. The app merges the bundled config with up to three additional layers in a fixed order. - Content — skill bundles (
skills/<name>/SKILL.md) and MCP packages (mcps/<name>/dist/index.js) that the config can reference. The app resolves these from a stack of roots, with the downstream root winning over the bundled one.
A downstream distribution typically ships a directory with this layout:
acme-cowork/
├── config.json # or open-cowork.config.json
├── skills/
│ ├── acme-reports/
│ │ └── SKILL.md
│ └── acme-tickets/
│ └── SKILL.md
└── mcps/
└── acme-crm/
└── dist/
└── index.js
Then launches the app with OPEN_COWORK_DOWNSTREAM_ROOT=/etc/acme-cowork.
Config merge order¶
flowchart LR
D["DEFAULT_CONFIG<br/>compiled-in"]
B["bundled<br/>open-cowork.config.json"]
P["OPEN_COWORK_CONFIG_PATH<br/>single file override"]
R["OPEN_COWORK_DOWNSTREAM_ROOT<br/>or CONFIG_DIR"]
U["per-user<br/>~/.config/<dataDirName>/config.json"]
S["managed system<br/>/etc/<dataDirName>/config.json"]
Active["Active config<br/>(deep-merged)"]
D --> B --> P --> R --> U --> S --> Active
style D fill:#e0e7ff,stroke:#6366f1
style B fill:#e0e7ff,stroke:#6366f1
style P fill:#fef3c7,stroke:#f59e0b
style R fill:#fef3c7,stroke:#f59e0b
style U fill:#dcfce7,stroke:#10b981
style S fill:#dcfce7,stroke:#10b981
style Active fill:#fae8ff,stroke:#a855f7,stroke-width:2px Layers later in the chain override earlier ones via deep merge — so a downstream layer only carries the keys it wants to change. Indigo layers are baked in; amber layers come from environment variables; green layers are user/admin overlays.
When the app starts, apps/desktop/src/main/config-loader.ts builds the active config by merging these sources in order (later entries override earlier ones):
DEFAULT_CONFIGcompiled into the app.- The bundled
open-cowork.config.jsonshipped inside the package. OPEN_COWORK_CONFIG_PATH— a single JSON file, if set and it exists.OPEN_COWORK_CONFIG_DIRorOPEN_COWORK_DOWNSTREAM_ROOT— a directory containingconfig.jsonoropen-cowork.config.json, if set and it exists.~/.config/<dataDirName>/config.json— per-user config./Library/Application Support/<dataDirName>/config.json(macOS),C:\ProgramData\<dataDirName>\config.json(Windows), or/etc/<dataDirName>/config.json(Linux) — managed system config.
deepMerge runs at each step, so downstream layers only have to carry the keys they want to change. <dataDirName> comes from branding.dataDirName in the active config.
Environment variables¶
| Variable | Purpose | Default |
|---|---|---|
OPEN_COWORK_DOWNSTREAM_ROOT | Root directory for a downstream distribution. Supplies config, skills, and MCPs. | unset |
OPEN_COWORK_CONFIG_PATH | Absolute path to a single override config file. Takes priority over CONFIG_DIR / DOWNSTREAM_ROOT config discovery. | unset |
OPEN_COWORK_CONFIG_DIR | Directory containing config.json or open-cowork.config.json. Merged after the single-file override. | unset |
OPEN_COWORK_SANDBOX_DIR | Root directory where sandbox threads create workspaces. | ~/Open Cowork Sandbox |
OPEN_COWORK_CHART_TIMEOUT_MS | Main-process chart render timeout. Clamped to [250, 10000] ms. | 1500 |
OPEN_COWORK_CUSTOM_SKILLS_DIR is set by the app itself when spawning the bundled skills MCP and is not intended for downstream use. The skills MCP rejects relative paths, filesystem roots, and the user's home directory; keep custom skill storage inside the app-managed runtime home.
Skills overlay¶
Skill bundles are resolved from these roots, in order (first match wins):
$OPEN_COWORK_DOWNSTREAM_ROOT/skills/<name>/<cwd>/skills/<name>/— the repo's ownskills/directory during development.<app>/Contents/Resources/skills/<name>/— skills shipped with the packaged app.
This means a downstream distribution can either replace a bundled skill (by shipping a directory with the same name) or add new skills that the downstream config references.
Skills only become visible to the runtime when they are listed under skills in the active config — a skill directory that nobody references is ignored.
See apps/desktop/src/main/runtime-content.ts and apps/desktop/src/main/effective-skills.ts for the resolution code.
MCPs overlay¶
MCP packages are resolved from:
$OPEN_COWORK_DOWNSTREAM_ROOT/mcps/<name>/dist/index.js<resourcesPath>/mcps/<name>/dist/index.js— MCPs shipped with the packaged app (mcps/charts,mcps/skills).
As with skills, the MCP must be declared in the active config (mcps section) before the runtime spawns it.
See apps/desktop/src/main/runtime-mcp.ts.
Environment placeholders in config¶
Config strings may reference environment variables using {env:NAME}.
For safety, placeholders only resolve when the variable name is explicitly listed in allowedEnvPlaceholders:
{
"allowedEnvPlaceholders": ["ACME_LLM_GATEWAY_URL"],
"providers": {
"custom": {
"acme-gateway": {
"options": {
"baseURL": "{env:ACME_LLM_GATEWAY_URL}"
}
}
}
}
}
Unknown placeholders fail config loading with a clear error. This is deliberate: Open Cowork does not implicitly pull arbitrary secrets from the host environment, so downstream configs have to opt each variable in.
Provider credentials the user enters in the app's UI take a separate path (through settings.json and provider.options.<runtimeKey> in the built OpenCode config) and do not need to be listed in allowedEnvPlaceholders.
Worked example¶
A downstream distribution that brands the app "Acme Cowork", ships one custom provider, one internal MCP, and one internal skill would look like:
/etc/acme-cowork/
├── config.json
├── skills/
│ └── acme-tickets/
│ └── SKILL.md
└── mcps/
└── acme-crm/
└── dist/
└── index.js
config.json:
{
"allowedEnvPlaceholders": ["ACME_GATEWAY_URL", "ACME_GATEWAY_KEY"],
"branding": {
"name": "Acme Cowork",
"appId": "com.acme.cowork",
"dataDirName": "acme-cowork",
"helpUrl": "https://internal.acme.example/cowork",
"sidebar": {
"top": {
"variant": "logo-text",
"logoAsset": "branding/acme-logo.svg",
"mediaSize": 36,
"mediaFit": "vertical",
"mediaAlign": "center",
"title": "Acme AI",
"subtitle": "Private workspace"
},
"lower": {
"text": "Acme internal build",
"secondaryText": "Support from Data Platform.",
"linkLabel": "Get help",
"linkUrl": "https://internal.acme.example/cowork-help"
}
},
"home": {
"greeting": "What should {{brand}} work on today?",
"subtitle": "Ask a question or delegate to an approved agent.",
"composerPlaceholder": "Ask {{brand}} anything",
"suggestionLabel": "Start with",
"statusReadyLabel": "Online"
}
},
"providers": {
"available": ["acme-gateway"],
"descriptors": {
"acme-gateway": {
"runtime": "custom",
"name": "Acme Gateway",
"description": "Internal LLM gateway.",
"defaultModel": "acme-large",
"credentials": []
}
},
"custom": {
"acme-gateway": {
"name": "Acme Gateway",
"defaultModel": "acme-large",
"options": {
"baseURL": "{env:ACME_GATEWAY_URL}",
"apiKey": "{env:ACME_GATEWAY_KEY}"
},
"models": {
"acme-large": { "name": "Acme Large" }
}
}
},
"defaultProvider": "acme-gateway",
"defaultModel": "acme-large"
}
// ...tools, skills, mcps, agents, permissions as needed
}
Launch the packaged app with:
OPEN_COWORK_DOWNSTREAM_ROOT=/etc/acme-cowork \
ACME_GATEWAY_URL=https://llm.internal.acme.example \
ACME_GATEWAY_KEY="$(cat /run/secrets/acme-gateway-key)" \
./Acme\ Cowork
Rebranding the packaged app¶
The build reads three env vars to set the product identity without forking electron-builder.yml:
| Variable | Default | What it controls |
|---|---|---|
APP_PRODUCT_NAME | Open Cowork | macOS menu bar + Dock name |
APP_ID | com.opencowork.desktop | Bundle identifier / reverse-DNS |
APP_ARTIFACT_PREFIX | Open-Cowork | macOS / Linux artifact filenames |
Example downstream build:
APP_PRODUCT_NAME="Acme Cowork" \
APP_ID="com.acme.cowork" \
APP_ARTIFACT_PREFIX="Acme-Cowork" \
pnpm --dir apps/desktop dist:ci:mac
The in-app brand name (window title, first-run copy, log prefixes) is driven separately by branding.name inside open-cowork.config.json or the downstream config overlay — see the Configuration section above.
Downstream builds can also configure sidebar and Home copy under branding.sidebar and branding.home. Those fields only affect UI surfaces: they do not change OpenCode runtime providers, agents, permissions, MCPs, or skills. For logo-backed sidebar variants, place image files under the repo-level branding/ directory and reference them with branding.sidebar.top.logoAsset, for example branding/acme-logo.svg. The app rejects absolute paths, traversal, remote URLs, and unsupported extensions.
Sidebar top branding can also tune the rendered media with branding.sidebar.top.mediaSize (16-96 pixels, default 28), mediaFit (vertical or horizontal; unset keeps the legacy square bounding box), and mediaAlign (start, center, or end for icon-only or logo-only placement).
The repo name, bundle identifier, and project namespace are separate concerns. Upstream now uses the public repo name open-cowork, but retains com.opencowork.desktop and .opencowork/ as the default internal identifiers for back-compat. Downstreams should only change APP_ID or branding.projectNamespace when they intentionally want a new install identity and are prepared to migrate existing state.
Localization (i18n)¶
Open Cowork ships with inline English strings. The config schema has an optional i18n overlay so downstream forks can localize without forking the codebase:
{
"i18n": {
"locale": "de-DE",
"strings": {
"dashboard.threads": "Gespräche",
"dashboard.cost": "Kosten"
}
}
}
Two things happen when this is set:
-
Intlformatters (formatNumber,formatCompactNumber,formatDate,formatCurrencyfromapps/desktop/src/renderer/helpers/i18n.ts) switch to the configured locale. Numbers and dates render as1.234.567/31.12.2026inde-DE,1,234,567/12/31/2026inen-US. -
String catalog —
t('dashboard.threads', 'Threads')looks up the configured translation and falls back to the inline English default when no translation exists. Partial catalogs are fine; untranslated keys stay in English.
The upstream source hasn't migrated every string to the catalog yet — only the highest-visibility ones (see docs/roadmap.md "Deferred Work"). Downstream forks that need broader coverage can either:
- Submit a PR migrating more strings to
t(key, fallback)in the renderer source, then translate the key in their config. - Fork and translate inline strings directly.
locale flows into every Intl formatter without string-catalog migration, so even an untranslated fork sees locale-appropriate numbers / dates / currencies.
Telemetry forwarding¶
Every in-app event tracked by telemetry.ts (app launch, auth login, session creation, perf-slow, error) is written to a local NDJSON file by default — no data leaves the user's machine. Downstream installs that want their own telemetry collector (PostHog, Mixpanel, an internal HTTP endpoint) set:
{
"allowedEnvPlaceholders": ["ACME_TELEMETRY_TOKEN"],
"telemetry": {
"enabled": true,
"endpoint": "https://events.acme.example/ingest",
"headers": {
"Authorization": "Bearer {env:ACME_TELEMETRY_TOKEN}"
}
}
}
Each tracked event is POSTed to the endpoint as JSON — fire-and- forget with a 2-second timeout. Failures are silent; the local NDJSON file stays the source of truth. headers is passed to fetch after normal config placeholder resolution, so auth tokens, CSRF tokens, or routing hints go through unchanged. Use {env:VAR} placeholders for secrets and list each variable in allowedEnvPlaceholders so the config file itself stays safe to commit.
Upstream distributions ship with telemetry disabled by default — no remote calls happen unless a downstream opts in.
Signing, notarization, and distribution¶
This repository ships unsigned artifacts by default. Downstream distributions that ship to end users should add:
- macOS code signing and notarization (via the standard
CSC_LINK,CSC_KEY_PASSWORD,APPLE_ID,APPLE_APP_SPECIFIC_PASSWORD,APPLE_TEAM_IDenvironment variables that electron-builder reads) - Linux package signing as required by the target repositories
- any internal release approval, artifact mirror, or provenance requirements
See Packaging and Releases for the upstream release flow those hooks plug into.