Skip to content

Channels & white-label

The same code base can be delivered as multiple channels. A channel decides what a given deliverable is called, what it looks like and where it upgrades from, and channels never upgrade into one another — this is a correctness requirement, not a configuration option.

Deciding “which channel is this stack, and can an upgrade silently turn it into another one”, plus what channel-related failures look like. It covers: the three channel types → where channel content lives and which fields exist → the release matrix (which tags build which channels) → the values that must agree on the deployment side → criteria → boundaries and failure behaviour (including diagnosing “branding disappeared after an upgrade”).

Prerequisites: channel content comes from the private channel repository (fetched at build time) and is injected into the image. This documentation always uses placeholders (<channel id>, <brand>, harness.example.com) — real channel identities never enter the public repository or this page.

ChannelPurposeDistribution surface
officialOfficial releasesUpdate server release.picoaide.com/official/ + GitHub Release (complete historical archive)
betaPre-release (version tags containing -beta / -rc / -alpha)Update server release.picoaide.com/beta/ + GitHub Pre-release
<channel id>Enterprise custom / white-label deliveryOnly via the update server release.picoaide.com/<channel id>/ (never in a public Release)

Each channel has its own directory on the update server, with a structure identical to the official one:

<channel>/latest.json
<channel>/releases/<version>/picoaide-server-<version>-amd64.zip
<channel>/releases/<version>/SHA256SUMS

Why channel content is built into the image instead of uploaded at runtime. Branding, copy, marks, data root and deep-link scheme are all injected at build time (docker build --build-arg CHANNEL=<channel id>), and the admin console deliberately offers no editor for them. Two reasons: auditability — “which brand is this machine running” can be read out of the image and compared byte for byte, rather than being a database row someone can edit; and immutability — once channel fields can be rewritten at runtime, “washing a customised deployment back to the official brand” is one mistaken edit away.

Why channels never upgrade into one another (refuse at startup instead of falling back). At startup the server verifies “the channel inside the image == the process channel”, and during update checks it verifies “the manifest’s channel_id == this deployment’s channel”. Any mismatch refuses: an invalid PICOAI_CHANNEL refuses to start; content conflicting with the process channel refuses to start; a mismatched manifest channel is reported as “update check unavailable” (not as “no new version”). Why not fall back to official: a fallback would let a customised deployment accept the official manifest, losing its branding and channel configuration after the upgrade, with no error anywhere.

Why one image archive carries identical tags in every channel (while the content differs). Channel differences live in the image content (/opt/picoaide/channel/ and the baked channel marker), not in the tag. That creates an operational constraint worth remembering: a second channel stack on the same host overwrites identically named tags when it runs docker load, so every archive additionally carries a channel-specific tag and a multi-stack host’s .env must point at it (see Upgrade, backup & rollback).

A channel is not a runtime configuration upload; it is injected at build time and frozen into the image:

image build --build-arg CHANNEL=<channel id>
├─ ENV PICOAI_CHANNEL=<channel id> ← runtime channel identity
├─ /opt/picoaide/CHANNEL ← authoritative declaration file (works even if the deployment sets nothing)
└─ /opt/picoaide/channel/ ← channel content (names / copy / marks / accent colour)

Channel content includes:

CategoryContent
IdentityDisplay name, short name (sidebar), window title, tagline
CopySign-in page and client welcome text, portal welcome text
AssetsLight and dark logos, favicon, accent colour
ClientData root directory, built-in server address, installer name (slug), application id, deep-link scheme, app origin scheme
Feature modulesThe switch for each of skills / WASM apps / connectors (features, all on by default; see below)
OptionalWhether the speech model ships with the client (desktop.speech_bundle_model, on by default; when on the installer grows by about +140–250 MiB), whether the system proxy may be used (desktop.allow_system_proxy)

The same content drives the client sign-in page, the client UI, the admin console sidebar and the portal — one configuration, consistent across the product. When the server is unreachable or older, the client renders the bundled copy of the branding and never falls back to the vendor mark.

desktop.app_origin_scheme (the origin scheme for WASM apps, <scheme>://<app_id>) is mandatory for every channel: missing / invalid / identical to the deep-link scheme / duplicated across channels ⇒ the build stops and that channel produces no artefact (no silent fallback). Branded channels must also provide the logo and app icon: missing assets are caught by the packaging checks instead of producing an installer that carries the vendor icon.

Which platforms a channel ships (desktop.platforms)

Section titled “Which platforms a channel ships (desktop.platforms)”

A channel package ships all three platforms (Windows / macOS / Linux) by default. If only some of them are needed, declare the delivery surface in the channel configuration:

"desktop": { "platforms": ["win", "mac"] }
  • The only valid values are win / mac / linux; omitting the key means all three (existing channels keep their exact behaviour);
  • Declaring it narrows four places at once: the platform jobs skip packaging undeclared platforms → the private R2 transfer only moves the declared ones → the image assembly only carries them (a declared platform with no artefact makes the build fail, rather than silently dropping an entry) → the portal download section and GET /api/client/v2/updates/manifest no longer list them;
  • An undeclared platform is omitted entirely from the portal (not shown as a grey “no installer available yet” card, which would look like we forgot to build it);
  • Dropping a platform saves one installer per version (Linux ≈ 350 MiB, macOS ≈ 380 MiB, Windows ≈ 430 MiB), and since each channel keeps the latest plus the previous version on the update server, the saving is doubled.

The official channel is the template for new channels: CI validates that channels/official/channel.json declares every key explicitly (the key list lives in packages/host/desktop/scripts/channel-constants.ts as CHANNEL_TEMPLATE_KEYS); a missing key fails the build and names it. So adding a channel = copy channels/official/ → change the identity fields → trim desktop.platforms and features, without reading source code to discover which keys exist. The three desktop.speech_* keys are overrides (absent = use the bundled model) and are deliberately not part of the template — putting them in would pin the paths.

Which feature modules a channel ships (features)

Section titled “Which feature modules a channel ships (features)”

A channel package ships every feature module (skills / WASM apps / connectors) by default. If a channel does not need one of them, turn it off explicitly in the channel configuration:

"features": { "skills": true, "apps": true, "connectors": false }
  • The only valid keys are skills / apps / connectors and the values must be booleans; omitting the key means all of them are on (the base features stay on, existing channels keep their exact behaviour);
  • The default is per key: {"connectors": false} leaves the other modules on — there is no need to spell the other switches out;
  • A mistake (an unknown module name, a value that is not a boolean — the string "false" reads as truthy in the client — or features not being an object) stops the build and that channel produces no artefact: the error names the field and the valid module names but never echoes the value (any string in a channel package would end up in a public log);
  • The official channel’s template lists all of these keys per module, so adding a channel shows at a glance which modules can be turned off.

Where this chain currently ends: for a brand channel the declaration is written into the channel configuration that ships with the client as features (the normalised switches, all keys present) and features_variant (a stable variant identifier such as skills,apps, connectors or none); the official channel has no such shipped configuration (it uses the built-in identity), and the channel content inside the server image is still the raw channel-repository copy. That identifier is the same string future consumers must share: the server image’s compile tags (Go -tags), the client assembly manifest and the admin console build argument (--build-arg FEATURES=<variant>) — the two image-side consumers resolve it at build time from the same source of truth instead of assuming the artefact already carries it. Three copies of the rule would produce a half-wired state (“present in the image, missing in the client”) with no failing check anywhere. Until those consumers are wired, changing features does not change any artefact. The full contract (including how to prove a module really is absent from an artefact) lives in the repository at docs/decisions/2026-10-13-channel-feature-modules.md.

Getting the speech model into a customer network (desktop.speech_*)

Section titled “Getting the speech model into a customer network (desktop.speech_*)”

Speech input runs locally, and the model weights ship with the client by default (desktop.speech_bundle_model defaults to true): the build puts the weights into the artefact and the assembly step points the upstream configuration at them — zero network, zero download. Only a channel that explicitly opts out (writing false) falls back to “download on first use”, and that download is direct (clients refuse to use any proxy by default), so in a network that reaches the internet only through an authenticated proxy an opted-out channel stays stuck at preparation failure.

FieldPurposeValue
desktop.speech_bundle_modelShip the weights with the client (on by default: works right after install, zero network, zero download)Boolean, default true; write false to opt out. When on, the installer grows by about +140–250 MiB (the weights are about 230 MiB; the increase depends on how well the platform’s package format compresses them)
desktop.speech_model_dirA pre-placed model directory (zero download)An absolute path, or a {default, darwin, linux, win32} platform map
desktop.speech_vad_pathA pre-placed Silero VAD file (still downloaded when only the model directory is pre-placed)Same as above
desktop.speech_model_originAn intranet mirror (HuggingFace-compatible, same model paths and file names)Only a http(s)://host[:port] shape (plain http:// is accepted too, matching the upstream configuration schema exactly); no path allowed

A few semantics worth knowing:

  • Bundled weights take precedence over a pre-placed directory or mirror: when both are configured the bundled payload wins (what is already on the machine beats a network configuration);
  • Pre-placed paths are not checksum-verified (upstream only checks that the file exists), so version matching is the deployer’s responsibility; a wrong path fails loudly with Speech model verification failed during preparation rather than degrading into “looks fine but transcribes nothing”;
  • Configuring modelOrigin removes the public fallback: an unavailable mirror means a failed download (an explicit source is an explicit intent);
  • A malformed value (relative path, unknown platform key, a mirror URL with a path, the string "true") makes the client fall back to downloading, but the build-time CI stops that channel — do not ship a misconfiguration to customer machines.

Release matrix (which tags build which channels)

Section titled “Release matrix (which tags build which channels)”
TriggerChannels builtNotes
Pre-release tag (contains a hyphen, e.g. vX.Y.Z-beta.N)beta onlyBranded channels’ clients are not produced on a pre-release tag — branded packages can only come from a release tag (the workflow only has pull_request and push triggers, with no workflow_dispatch manual entry point)
Release tag (plain vX.Y.Z)All channels (official + beta + every branded channel)A branded channel’s image and installers only appear in that channel’s own update directory
Not a tag (PR / branch push)official onlyUsed for gates and smoke tests, not as a deliverable

⇒ When adding or changing a branded channel field: make the change in the channel repository and push it, then wait for a release tag; otherwise customers keep receiving packages with the old behaviour. Criterion: whether that channel’s latest.json on the update server already points server.version at the new release.

Values the deployment side must keep consistent

Section titled “Values the deployment side must keep consistent”
ValueDecided byNotes
This deployment’s channelThe image (/opt/picoaide/CHANNEL).env’s PICOAI_CHANNEL may be left empty; if set it must match the image
Update manifest addressPICOAI_UPDATE_ENDPOINT, empty = this channel’s default directoryEmpty does not mean off; write off to disable
The manifest’s channel_idThe latest.json in that channel’s directoryMust equal this deployment’s channel
The image tag (multi-stack hosts)The <channel id>-<version> tag in each archiveEach stack’s SERVER_IMAGE must point at its own channel tag

The server enforces this at startup and during update checks:

  • An invalid PICOAI_CHANNEL → refuses to start (it never falls back to official);
  • The image’s channel conflicting with the process channel (typically because .env / compose overrode the image declaration) → refuses to start;
  • A manifest channel_id that differs from this deployment → reported as “update check unavailable”, not as “no new version”; channels never silently upgrade into one another.

Upgrading from an older version: compose no longer hard-codes a channel. If .env still carries a hand-written PICOAI_CHANNEL=official, a branded deployment is judged inconsistent — delete that line.

Terminal window
cd /opt/picoaide
DOMAIN=$(grep '^DOMAIN=' .env | cut -d= -f2-)
# 1) the channel baked into the image (authoritative declaration)
docker exec picoaide-server cat /opt/picoaide/CHANNEL
# 2) the channel the server reports publicly
curl -sk --resolve "$DOMAIN:443:127.0.0.1" "https://$DOMAIN/api/client/v2/channel"
# 3) the update endpoint resolved at startup
docker compose logs --tail=200 server | grep -i 'channel resolved'
# 4) the channel in the manifest (the one clients verify too)
curl -sk --resolve "$DOMAIN:443:127.0.0.1" "https://$DOMAIN/api/client/v2/updates/manifest"
#CriterionPassing looks likeFailing looks like
1The image channel is readablecat /opt/picoaide/CHANNEL prints this stack’s channel idThe file is missing ⇒ the image is not a released package
2The public channel agrees/api/client/v2/channel’s channel_id == criterion 1They differ ⇒ the channel was overridden and the container may already have refused to start
3The update endpoint is rightthe log’s channel resolved: … (update endpoint …) points at this channel’s directoryAn empty endpoint ⇒ update checks were explicitly set to off; another channel’s endpoint ⇒ the upgrade will be refused (never a silent cross-channel upgrade)
4The manifest channel agreeschannel_id == criterion 1Mismatch ⇒ the server reports the update check as unavailable
SymptomCriterion (how to confirm)Recovery
“New version available” never appearsdocker compose logs server prints manifest channel "…" != this server's channel "…"The three channel values disagree: most often PICOAI_CHANNEL was changed without the endpoint (or the reverse); it may also be that the endpoint is set to off. The server caches for 6 hours, so a delay right after a release is normal
The container exits at startup, printing an invalid channel configurationthe log names PICOAI_CHANNELThat value (or the image’s channel marker) is not a valid channel id: fix the spelling or remove the override — never count on a fallback
The container exits at startup, printing a channel mismatchthe log names the image channel and the process channel.env / compose overrode the channel and it differs from the image content: remove the override or make it match the image
Branding changed after an upgrade / the portal shows vendor brandingcat /opt/picoaide/CHANNEL ≠ /api/client/v2/channel’s channel_idWrong channel image: point .env back at this channel’s channel tag and recreate; verify the image id as described in Upgrade, backup & rollback and do not keep upgrading from it
Customers received old-behaviour packagesThat channel’s latest.json still shows the old server.versionBranded channels are only produced on a release tag: wait for one, or confirm the channel repository change was pushed (CI fetches from the channel repository)
The client shows different branding from the serverThe client’s sign-in page / sidebar brandingThe client’s branding comes from the bundled copy (used when the server is unreachable or older): reinstall the client delivered with the server (see Client delivery & updates)
Two channel stacks on one host overwrite each other’s brandingOne stack’s container image id points at another channel’s imageChannel tag overwrite: redo the multi-stack section of Upgrade, backup & rollback (.env must point at <channel id>-<version>)

Client data isolation (channels can coexist)

Section titled “Client data isolation (channels can coexist)”

A channel-scoped client keeps its configuration, sessions, connector credentials and browser data in its own data directory, so clients of different channels can live on the same machine without interfering:

  • Official channel: ~/.picoaide-harness;
  • Pre-release channel (beta): shares the official release’s data directory — a pre-release is the release’s own verification stage, so sign-in state, settings and sessions must carry over (this is deliberate: a separate directory would make pre-release users lose their existing sessions after upgrading);
  • Branded channels: a channel-derived separate directory, which must not share the official one (the two clients may use different session-format generations, and sharing would silently fork the data);
  • The application’s user-data directory and the Windows application identity are channel-scoped too, so two clients never steal each other’s single-instance lock.

Browser SSO callbacks use the channel’s own deep-link scheme, so a sign-in callback never lands in another channel’s client.

Employee clients do not need to know which channel they belong to: they only ask “the server I signed in to”, and the server’s channel is structurally decided by its own image. So a “client channel differs from server channel” state cannot exist, and no cross-channel upgrade can wash a deployment over.

Bespoke installers are delivered straight from the server’s portal (see Client delivery & updates); employee machines never need the internet.