Hosted portal
Self-service accounts, teams, invite codes and team invites for the hosted product (CLAPILOT_DEPLOYMENT_ROLE=portal).
The hosted product has three parts:
- One portal at
web.clapilot.com. It handles accounts, teams, invites and later billing. - One cell per team. A cell is a normal Clapilot instance provisioned by Fleet, with its own database, workspace and containers.
- A router in front. It sends each signed-in user to their team's cell.
Portal and cells run the same image as every dedicated instance. The hosted behaviour is switched on by deployment role, never by a fork (see VISION.md, "One codebase, one image" and "Isolation is enforced by infrastructure, not by filters").
| Step | What | Status |
|---|---|---|
| 0 | Container hardening: no privileged web container, no-new-privileges, constant-time internal secret checks | shipped |
| 1 | Portal mode: accounts, teams, invite codes, team invites, email verification, password reset, platform admin console | done |
| 2 | Cell CLAPILOT_AUTH_MODE=portal: cells trust a router-signed identity and create users on first request | done |
| 3 | Router (entrypoint.sh router): session-based routing to the team's cell, host-based routing for public links, WebSocket/stream passthrough; admin cell registry and team assignment | done |
| 4 | Hosted-cell Fleet profile and automatic cells: warm pool, automatic creation and assignment, private edge network with egress firewall, per-cell LiteLLM keys with budgets, usage view and kill switch, secret-free shell env | done (process-level shell sandbox still open) |
| 4b | Platform admin roles and in-app configuration: user and team management, hub connection, model provider and email configured in the web UI instead of env variables; cells pull their managed model provider from the portal | done |
| 5 | Apple/Android clients default to web.clapilot.com and offer Register | done (Android: team management opens the web account page) |
Deployment role
CLAPILOT_DEPLOYMENT_ROLE selects what a deployment serves:
instance(default, and any unknown value): a normal Clapilot workspace. All portal routes and pages answer404, and/loginshows the instance login.portal: the deployment serves only the portal. Two layers enforce this:- The request middleware (
src/lib/portal/paths.ts) allows the portal pages (/login,/register,/verify-email,/forgot-password,/reset-password,/invite/<token>,/account,/portal-admin),/api/portal/**,/api/build-infoand static assets. Everything else answers404, including all workspace pages,/api/auth/*, public share links and agent runtime routes./redirects to/account. - In
entrypoint.shandsrc/instrumentation.ts, every workspace background worker is skipped: task scheduler, RAG indexer, email/Google/iCal pollers, instance cleanup, social publisher, storage monitor, hub/fleet workers.
- The request middleware (
A portal deployment runs the web container, the router (entrypoint.sh router) and PostgreSQL. It does not need the agent or streamer containers. Set CLAPILOT_TRUST_PROXY_HEADERS=true when the portal is reachable only through a proxy that overwrites X-Forwarded-For. Without it, all clients share one rate-limit bucket, which gets a wider global budget.
Product rules
- Registering without an invite:
- creates an account (unverified until the email link is opened), a new team named after the registrant, and the owner membership
- in
invite_codemode, redeems one use of an invite code in the same transaction
- Accepting a team invite:
- joins the team with the invite's role (
adminormember) and needs no invite code - a new invitee registers on the invite page; the account starts verified, because the emailed link proves the address
- an existing account must be signed in with the invited email and must not belong to another team
- joins the team with the invite's role (
- One team per account.
portal_team_members.account_idisUNIQUE; dropping that constraint is the path to several teams later. - Registration mode (platform admin setting,
app_settings.portal_registration_mode):invite_code(test phase default): new accounts need a valid codeopen: anyone can registerclosed: no new accounts- Team invites work in every mode.
- Team size: members plus open invites may not exceed
portal_teams.max_members, or the admin defaultapp_settings.portal_default_max_team_members(10). - Roles:
- The owner renames the team, invites admins or members, and changes or removes anyone except themselves.
- Admins invite members and remove plain members.
- Members only see the member list.
- Nobody can remove or demote the owner.
Identity and security
- Separate accounts. Portal accounts live in
portal_accounts, separate from the instanceuserstable.- The session cookie is
clapilot_account, signed like instance sessions but withaud: "portal-account". - Instance session verification rejects any token with an audience, and portal verification requires this one. Neither kind of token can ever pass as the other.
- The session cookie is
- Passwords: scrypt hashes, like instance users, with a minimum of 10 characters.
- Session revocation: portal session tokens carry
sv, the account'ssession_version. A database trigger bumps the version whenever the password hash changes (password change, reset link, set-password link), and both the portal and the router reject tokens with an older version. The router caches accounts for 15 s; a session newer than the cached account (the one that just changed the password) makes it re-read the account, which also ends the older sessions immediately. Otherwise they end within that window. - Emailed tokens: 32 random bytes; only their SHA-256 is stored.
- Verification links are valid for 48 hours, reset links for 2 hours, team invites for 7 days.
- Issuing a new token revokes older ones of the same purpose.
- Pages that carry tokens send
Referrer-Policy: no-referrerand are not indexed.
- No account enumeration:
- Registering an existing email returns the same response as a new registration. The mailbox gets a notice with a sign-in link, or a fresh verification link if the account is still unverified.
- An invalid invite code fails before the email is looked at.
- Forgot-password and resend-verification always answer the same way.
- Invite codes:
- 16 Crockford base32 characters, shown as
XXXX-XXXX-XXXX-XXXXand accepted case- and separator-insensitively. - Redemption is an atomic
use_count < max_usesupdate. - Failed code attempts have their own rate-limit budget.
- Codes are stored in plaintext on purpose: they are low-sensitivity test-phase gates that admins must be able to copy again.
- 16 Crockford base32 characters, shown as
- Rate limits:
- Login uses the instance login limiter design, in a separate instance.
- Register, code failures, resend, forgot, reset, invite accept and invite create have hourly per-IP budgets.
Portal emails (verification, "account already exists", password reset, welcome with a set-password link, team invite, admin test email) are sent through the portal deployment's system mailbox (sendSystemEmail), configured in Portal admin → Settings → Email (also Settings → Platform → Settings in the admin's workspace). The From header shows the product name (Clapilot <address>). The optional SMTP user name (app_settings.default_smtp_user, migration 355) is used when the mailbox does not sign in with the sender address; empty uses the address. Port 465 connects with implicit TLS (certificate checked), other ports use STARTTLS. They are localized in German, English and Italian and use the same layout as instance invitation emails. Team invites set the inviter as Reply-To.
Cloudflare Email Service (used for web.clapilot.com): sender [email protected] on the onboarded domain clapilot.com, SMTP server smtp.mx.cloudflare.net, port 465, user name api_token, password = a Cloudflare API token with only the Email Sending: Edit permission. It requires the Workers Paid plan (3,000 emails a month included). Onboarding adds the sending records on cf-bounce.clapilot.com plus DMARC; Email Routing can forward replies to [email protected] to a real mailbox.
When SMTP is not configured or delivery fails, the flow still completes, and the delivery status is stored on the token row:
- Team invites return the link to the inviting owner or admin, who can share it directly.
- Password links: Portal admin → Users → Send password link returns the link to the admin when no email could be sent. Stored tokens are hashes and are never returned.
Pages
/register: name, email, password and, ininvite_codemode, the invite code (prefilled from?code=)./login: portal sign-in, with a "resend link" action for unverified accounts.?next=accepts only portal paths; without it, sign-in opens the workspace (/)./verify-email?token=,/forgot-password,/reset-password?token=: token flows. Verification and reset sign the account in and open the workspace, as does accepting an invite./invite/<token>: shows team and inviter. It then either registers and joins, signs in to join, or explains why the invite cannot be used./account, only shown while the account has no usable workspace (with a ready one, the router opens the workspace instead):- team name (the owner can rename it) and workspace status:
pendingreads "being prepared" until step 4 assigns cells - member list with role controls, pending invites with resend and revoke, and the invite form with a fallback link
- a "create workspace" state for accounts without a team
- team name (the owner can rename it) and workspace status:
/portal-admin(platform admins only;404for everyone else), see Platform admins.
Platform admins
portal_accounts.platform_role is user (default) or admin. Platform admins manage every account and team on the portal and configure it; nothing beyond bootstrap wiring comes from env variables. Team roles (owner, admin, member) are separate and only apply inside one team.
Guards: an admin cannot change, disable or delete their own account, and the portal always keeps at least one active admin (last_admin, 409).
Where admins work. Inside a hosted workspace, everything lives in the app settings:
- Settings → Admin → Users (team owners and admins) manages the Clapilot team: team name, members and roles, invitations with a fallback link. The workspace's own user list is not shown, because the team decides who can use the workspace.
- Settings → Platform (platform admins only, whatever their team role) has Users, Teams, Invite codes, Cells and Settings.
- Settings → Profile → Account (everyone): the name is saved on the Clapilot account (
PATCH /api/portal/me) and copied into the workspace; the sign-in email is read-only. Change password asks for the current password (POST /api/portal/password/change, minimum 10 characters, failed attempts are rate-limited) and signs out every other browser and device of the account; the current session stays signed in. - The browser calls
/api/portal/**on the same origin; the router forwards those requests to the portal with the account cookie, so the portal API stays the only place that authorizes them. - The hub is the platform admins' workspace ("master instance"). The Fleet hub is a full Clapilot instance (web, agent, streamer) that runs in portal auth mode under the fixed cell id
00000000-0000-4000-8000-00000000c0de. The portal registers it as the cellplatform-hubwhenHOSTED_HUB_URLandHOSTED_HUB_IDENTITY_SECRETare set:scripts/db-seed-admin.mjsreserves it for the bootstrap admin's team (bound_team_id, so provisioning never hands it to another team) and makes it that team's workspace while the team has none. Portal, router and hub shareHOSTED_HUB_IDENTITY_SECRETinstead of a master-derived secret, because the hub must know its secret before it starts. Platform admins therefore sign in at the public URL and land in the hub, which shows the Hub group (Monitoring and tracking, Fleet, subscription usage, configuration) next to Platform, and the Store with publishing (the hub's own catalog,hub_mode=local). On first start,CLAPILOT_HUB_MODE=localputs it in hub mode; withoutCLAPILOT_HUB_SHARED_SECRETit generates a random shared secret, which admins can rotate in Settings → Hub → Configuration. An existing team switches to the hub in Platform → Teams (cellplatform-hub); its previous cell is then retired like any other. The hub's web UI is only reachable through the router (portal sign-in); Fleet connectors and the portal keep using its API port. Agents in the hub workspace run next to the Fleet secrets, as on a dedicated hub, so only platform admins should be on that team. - In hosted team workspaces the Hub group and the local password page are hidden; in the hub's own workspace (
hub_mode=local) the Hub group is shown, the password page stays hidden (sign-in and passwords belong to the Clapilot account).GET /api/app-settingsreportshosted_workspace: truethere. Dedicated instances are unchanged; the Platform pages answer404on them.
The portal's own /account and /portal-admin pages stay for accounts without a ready workspace: no team yet, a workspace still being prepared or failed, or a suspended team. /portal-admin remains a fallback for recovery; on a fresh install the hub is the admins' workspace from the first start.
/portal-admin has five tabs:
- Users: search by name, email or team.
- Create a user with their own new workspace or as a member of an existing team, optionally as platform admin. The person gets a welcome email with a set-password link (valid 7 days; setting the password also verifies the address). When no email could be sent, the link is shown to the admin.
- Per user: edit name and email (
email_in_use, 409), platform admin switch, enabled switch, send password link, mark email confirmed, delete. - Deleting the owner of a team with other members needs an ownership transfer first (
ownership_transfer_required, 409). Deleting the last member deletes the team and retires its cell.
- Teams: search by team or owner. Per team: name, member limit (empty uses the default), active switch (kill switch), cell assignment, and the member list with role changes (choosing Owner for another member transfers ownership), removal, and adding an existing account without a team.
- Invite codes: create batches with uses, expiry and note; copy; enable or disable; see usage.
- Cells: automatic cells with status, team, usage and environment; manual registration stays available for cells outside Fleet.
- Settings (
GET|PATCH /api/portal/admin/config), one section each:- General: public URL (
app_settings.public_base_url), registration mode, default team size, warm pool size (app_settings.portal_warm_pool_size, 0–50, default 1). - Email: sender address, SMTP server and port, SMTP password; test email.
- Fleet hub: hub URL and the portal service key generated in the hub (Fleet → Hosted cells); connection test.
- Model provider: LiteLLM gateway URL and master key, the model allowlist (loaded from the gateway catalog; models the gateway marks as image, video, speech, transcription, realtime, embedding, rerank, moderation, search or OCR models are left out), default model, budget per team and budget period, and the optional media models (see below).
- General: public URL (
Secrets (SMTP password, hub service key, LiteLLM master key) are write-only: the API only reports whether one is set. Hub and model settings live in portal_config (hub, llm), encrypted at rest (enc:v1, AES-256-GCM keyed from CLAPILOT_AGENT_CONFIG_SECRET or AUTH_SECRET). Saving the model provider updates every existing cell key to the new allowlist and budget and pushes the gateway's firewall exception to the hub.
The bootstrap admin (ADMIN_EMAIL / ADMIN_PASSWORD) is created once as a verified platform admin with its own team when scripts/db-seed-admin.mjs runs with CLAPILOT_DEPLOYMENT_ROLE=portal. After that the seed leaves the account alone, so a password changed in the app survives restarts and deploys. The only repair it makes: if no active platform admin is left, the bootstrap account becomes admin again. Further admins are promoted in Users.
Cells and the router
browser ──► router (web.clapilot.com)
├─ portal pages, /api/portal/** ──► portal web container
├─ /api/auth/login ──► router: portal sign-in, answered in the instance shape (native apps)
├─ /api/auth/logout ──► portal (sign-out lives there)
├─ everything else, signed-in account ──► team cell + x-clapilot-portal-identity
└─ Host = a cell's public host ──► that cell, no identity (share links, booking, webhooks)
Router (services/clapilot-router, started with entrypoint.sh router, same image):
- Reads the
clapilot_accountcookie, orclapilot_session(native apps keep only that one); both hold the same portal token, verified with the portalAUTH_SECRET. Looks up the account, team and cell in the portal database, with a 15 s cache (3 s while the team has no ready cell). After a successful portal write (POST/PATCH/DELETEto/api/portal/**, e.g. a name change) it re-reads the signed-in account before answering, so the next identity already carries the change. - Page views of accounts without a ready cell are redirected to
/account; page loads of/accountwith a ready cell are redirected to/(same account state, so the two never loop). API calls without a ready cell get409withcode: "workspace_pending"or"workspace_suspended". Anonymous page views go to/login, anonymous API calls get401. - Strips client-supplied
x-clapilot-portal-identityheaders and theclapilot_accountandclapilot_sessioncookies before anything reaches a cell. Removesclapilot_session/clapilot_accountfrom cellSet-Cookieheaders. - Build assets (
/_next/static, root images) go to the upstream serving the user's pages, with a fallback to the other on404. - Proxies streaming responses unbuffered and tunnels WebSocket upgrades (live voice).
- Hub catalog for cells:
GET/HEADrequests to/api/v1/{modules,skills,widgets,agents}[/…]that arrive on the edge aliasclapilot-hosted-router(only cells reach it) go to the Fleet hub configured in the portal (Settings → Fleet hub URL, cached 60 s), without cookies or identity. Publishing (…/publish), other methods and the public host never reach the hub. In a cell (portal auth mode, not the hub itself) the Module, Skills, Widgets and Specialist agents stores read and install from that catalog; publishing answers403("only possible in the platform workspace"), because cells hold no hub secret. - Env:
DATABASE_URL(portal DB),AUTH_SECRET(portal, also decrypts the cell master secret),CLAPILOT_ROUTER_PORTAL_UPSTREAM(defaulthttp://clapilot:3000),CLAPILOT_ROUTER_PORT(default8080),CLAPILOT_TRUST_PROXY_HEADERS. Health check:GET /__router/health. Answers503for workspace traffic until the portal has created its cell master secret.
Identity assertion. For every signed-in workspace request the router adds x-clapilot-portal-identity: base64url(JSON).base64url(HMAC-SHA256) with { v: 1, sub (portal account id), email, name, role (owner/admin/member), team, cell, lang, iat, exp }, valid for 120 s. The HMAC key is the cell's own secret, HMAC-SHA256(masterSecret, "clapilot-cell-identity:v1:<cellId>"), so a leaked cell secret can only forge identities for that one cell. The master secret is generated by the portal on first use and stored encrypted in portal_config (cells); the router reads it from there (60 s cache).
Cells (CLAPILOT_AUTH_MODE=portal, normal instance deployments otherwise):
- The middleware and
getCurrentUser()accept only a valid assertion forCLAPILOT_CELL_IDsigned withCLAPILOT_PORTAL_IDENTITY_SECRET. Localclapilot_sessioncookies are ignored, and/api/auth/login,/api/auth/password-setupand/set-passwordanswer404. Agent-system tokens, internal secrets and voice-device tokens work as before. POST /api/auth/updatesaves only the display name (the portal account owns it; the clients save it there first). Email or password changes answer403with a localized message, never401, because native clients treat401as an expired session.- Users are created on first request (
users.portal_account_id, unusable random password) and kept in sync on every request: email, name, and role (team owner or admin →admin, member →mitarbeiter). An existing unlinked user with the same email is linked instead of duplicated; a user linked to another account is never taken over. - Removed members keep their cell user row for attribution but can no longer reach the cell once the router cache expires.
/loginredirects toCLAPILOT_PORTAL_URL/login. Settings → Users shows "Team members are managed in your account" with a link to/account, hides invite, reset and delete actions, and/api/admin/usersmutations answer409withcode: "managedByPortal". The native apps show the team instead (see Native apps).
Manual cells. Automatic cells need no manual steps. For a cell outside Fleet, register it in Portal admin → Cells → Add cell manually with its internal URL (what the router connects to) and optional public host, then copy Show environment into the cell deployment:
CLAPILOT_AUTH_MODE=portal
CLAPILOT_CELL_ID=<cell id>
CLAPILOT_PORTAL_IDENTITY_SECRET=<derived per-cell secret>
CLAPILOT_PORTAL_URL=https://web.clapilot.com
Then assign the team to the cell in Portal admin → Teams. One cell serves exactly one team (portal_teams.cell_id is unique), and a cell is bound to the first team it served (portal_cells.bound_team_id): it keeps that team's data and is never assigned to anyone else (cell_bound_to_other_team, 409). A cell whose team was deleted is retired: disabled, its model key blocked, and it cannot be re-enabled. The team's workspace becomes ready, and the account page shows Open workspace. Also set the cell's public base URL to its public host so shared links use it.
Production base URL. Portal emails and cell env snippets use the public URL from Portal admin → Settings → General (app_settings.public_base_url) (e.g. https://web.clapilot.com). In production the portal refuses to build links from request headers when it is not set, to prevent Host-header injection into reset and invite links.
Automatic cells (step 4)
portal (web.clapilot.com host) Fleet hub (.24, hub_mode=local) cell host (connector)
provisioning loop ── POST /api/hub/fleet/hosted-cells ──► createInstance(profile=hosted_cell) ──► deploy job
(warm pool + waiting teams) (service key) minimal env, no tunnel edge network + firewall,
◄── GET /api/hub/fleet/hosted-cells/{id} (status running) ── then compose up
assign waiting team → cell_status ready → "Open workspace"
Portal provisioning loop (portal role, every 15 s, advisory-locked):
- Moves
provisioningcells toactive(orfailed) from the hub status. - Assigns waiting teams (oldest first) to active, unassigned cells.
- Requests new cells for waiting teams plus the warm pool target (Settings → General, default 1), at most 3 per tick. Only cells that never served a team count as free. Cells are created on demand: with warm pool 0 a new team waits for its own cell (about 1–2 minutes, shown as "being prepared"); the warm pool only keeps that many spare cells ready.
- Issues model keys for cells created before the model provider was configured.
The loop does nothing until an admin has configured the Fleet hub in Settings.
With a warm cell available, a new team gets a ready workspace within one tick. Each request creates the portal_cells row first, derives the cell's identity secret from it and passes it to the hub as hosted-cell env. Once the hub accepted the cell and a model provider is configured, it issues the cell's LiteLLM key and stores it encrypted in portal_cells.llm_key_encrypted; the key never goes through the hub.
When the hub refuses a cell (for example no online machine with free capacity), the request leaves no row and no key behind. The loop stops requesting cells for 5 minutes, and Portal admin → Cells shows the hub's reason and the time of the next attempt until a request succeeds. The machine's instance limit is set in the hub (Fleet → machine); retired cells of deleted teams keep their slot until the hub deletes them. The loop pauses (and logs) while the public URL is not configured.
Hub hosted_cell profile (hub_fleet_instances.profile):
- Name and hostname: named
cell-<8 hex>, hostname<name>.hosted.internal. No Cloudflare tunnel, no SIP ports, no external reachability check. - Minimal env (
generateHostedCellEnv): the cell's own database and secrets,CLAPILOT_AUTH_MODE=portal,CLAPILOT_SHELL_ENV_POLICY=scoped, RAG indexer off, and only these overrides from the portal: cell id, identity secret, portal URL, public URL,TZ. Model access is not part of the env (see Model access). Fleet-wideenv:*defaults and the hub shared secret are never copied into hosted cells, because their users can read the cell env. - Compose: only the web container joins the external
clapilot-hosted-edgenetwork (alias<project>-web, bound to0.0.0.0). Agent, streamer and postgres stay on the cell network. Both networks carry theclapilot.hostedlabel. Memory and pid limits per service;no-new-privilegeseverywhere;pull_policycomes from the hub's Fleet → Hosted cells setting (missingfor air-gapped or preloaded images). - Hub API for the portal:
GET(connection test),POST /api/hub/fleet/hosted-cells { overrides },GETandDELETE /api/hub/fleet/hosted-cells/{instanceId},PUT /api/hub/fleet/hosted-cells/settings { egressAllow }. The API authenticates only withAuthorization: Bearer <portal service key>and answers404when hub mode is off or no key was generated. - Hub UI, Fleet → Hosted cells: generate (shown once), rotate or revoke the portal service key; image pull policy; read-only list of the firewall exceptions the portal maintains. The values live encrypted in
hub_fleet_secrets(hosted_*keys, hidden from the generic Fleet secrets panel).
Connector (v1.5.2) on the cell host. For hosted deploys it:
- Ensures the edge network exists.
- Creates the containers with
compose up --no-start. - Rebuilds the
CLAPILOT-HOSTEDiptables chain (hooked intoDOCKER-USER) in the Docker host's network namespace. On Docker Desktop that is the Linux VM (on Windows with Docker Engine in WSL2, the distro itself), reached with a short-lived privileged helper from the instance image throughnsenter. The chain is replaced in a singleiptables-restore --noflushtransaction and rebuilds run one at a time, so concurrent deploys can never leave a half-built chain that drops replies to LAN clients. - Only then starts the cell.
For every clapilot.hosted subnet the chain returns established flows, traffic within the same subnet, and the allowed host:port pairs (e.g. 192.168.178.4:41805 for the NAS LiteLLM). The portal derives them from its model provider URL when it is a private address and pushes them to the hub; connectors receive them with every heartbeat (hosted.egressAllow) and rebuild the chain when they change. It drops the private ranges 10/8, 172.16/12, 192.168/16, 169.254/16 and 100.64/10: the home LAN, the Docker Desktop host and other bridges. Rules disappear when Docker restarts, so the connector re-applies them every 5 minutes.
Model access. Configured in Portal admin → Settings → Model provider. Each cell gets its own LiteLLM virtual key (alias clapilot-cell-<cellId>) with max_budget per budget period (default 20 USD per 30d), restricted to the allow-listed models. The master key stays on the portal.
Cells pull their managed provider from the portal: 10 s after start, every 30 s while it has no model access yet, then every 5 minutes a cell calls POST /api/portal/cell-sync, first through the router's edge-network alias http://clapilot-hosted-router:8080, then through its public portal URL. The request is signed with the cell's identity secret (x-clapilot-cell-id, x-clapilot-cell-timestamp, x-clapilot-cell-signature = HMAC-SHA256(identitySecret, "clapilot-cell-sync:v1:<cellId>:<ts>"), ±300 s). The answer holds the gateway URL (…/v1), the cell's own key, the allowlist and the default model, or suspended: true. The cell stores it as the provider clapilot-hosted (openai_compatible, metadata.managedBy = "portal"), which becomes the default only when no other provider is. Workspace admins see it as Managed by Clapilot and can pick it as default, but cannot edit or delete it; a suspended team or retired cell disables it.
Media models (optional). Under Model provider → Media models the platform admin can also offer models for image creation and editing, video, speech output (TTS), speech input (STT) and live voice, each with a default. After Load models, each feature lists the gateway models of the matching kind (LiteLLM /model/info mode: image_generation, video_generation, audio_speech, audio_transcription, realtime); models without a kind are matched by name, and Show all models lists the whole catalog. They run on the same gateway and through the same cell key: the key's allowlist is the chat models plus every media model. They are not added to the chat model list. The cell writes them into its media model catalog (app_settings.media_model_catalog):
- Image, TTS, STT and live voice use the provider
clapilot-hosted. Image entries are marked Both (create and edit), except for create-only models (imagen*,dall-e-3,ideogram,t2i), which are marked Text to image, so edits never go to them. - Video uses the media-generation provider
openai-compatible-video-clapilot-hosted(/videos), which the cell points at the platform's video models. - The workspace's own media providers and entries stay as they are. A platform default becomes the workspace default only while the workspace has not picked one of its own; a platform model it starred stays its default.
- Removing a capability's models (or suspending the team) removes those entries in the cell at the next sync; the video provider is switched off when no video entry is left. Entries the workspace deletes come back at the next sync while the portal still offers them.
What works through the gateway: images (/images/generations, /images/edits), video (/videos), TTS (/audio/speech) and STT (/audio/transcriptions). Live voice needs realtime sessions on the gateway, so check it in a cell before offering it. Dictation and audio in Notes still use the workspace's own OpenAI or Gemini provider. Only list models that are paid by API key. Subscription-backed routes (e.g. chatgpt-pro/* or the Claude subscription bridge) must never be offered to other people's cells.
Admin controls:
- Cells shows each cell's status and its Usage (LiteLLM spend against budget).
- Teams has an active switch: suspending a team routes it to its account page only and blocks its cell's LiteLLM key.
- Disabling a cell blocks its key; enabling it unblocks it.
Shell environment. With CLAPILOT_SHELL_ENV_POLICY=scoped, agent shells and their child processes get no variables matching secrets, tokens, passwords, API keys, credentials, DATABASE_URL or PG*. Run-scoped capabilities are passed explicitly. This removes casual exposure, but a shell running as the same user could still read the runtime's /proc/1/environ and the state directory; a process-level sandbox for hosted cells is still open.
Running it:
- On the cell host, start
docker-compose.hosted-portal.yml(postgres, portal, router on the edge network, cloudflared, and the hub with its own database, agent and streamer). Its env is bootstrap only:POSTGRES_PASSWORD,PORTAL_AUTH_SECRET,PORTAL_OPERATOR_EMAIL/PORTAL_OPERATOR_PASSWORD(first platform admin),CLOUDFLARE_TUNNEL_TOKEN,HUB_POSTGRES_PASSWORD,HUB_AUTH_SECRET,HOSTED_HUB_IDENTITY_SECRET(random, shared by portal, router and hub). - Sign in at the public URL as the bootstrap admin; you land in the hub. Open Settings → Hub → Fleet → Hosted cells and generate the portal service key. The cell host runs the Fleet connector ≥ 1.5.2.
- Fill in Settings → Platform → Settings: public URL, email, Fleet hub (URL
http://hub:3000and the key, then Test connection), model provider (URL and master key, save, Load models, pick the allowlist).
Windows cell hosts. Run Docker Engine inside a WSL2 distro rather than Docker Desktop, so the connector runs as a normal systemd service and the firewall lands in the distro's own netns. The distro needs systemd=true in /etc/wsl.conf. .wslconfig needs networkingMode=mirrored (router reachable on the Windows host IP), [general] instanceIdleTimeout=-1 and [wsl2] vmIdleTimeout=-1: otherwise WSL stops the distro, and every cell with it, a few minutes after the last session closes. A scheduled task that runs wsl.exe -d <distro> --exec /bin/true at logon starts it again after a reboot. The firewall still blocks the Windows host and LAN services from cells in this setup.
Native apps (step 5)
The native apps talk to web.clapilot.com the same way they talk to a dedicated instance: the router serves the instance API on the same origin.
Server contract
- Sign-in:
POST /api/auth/login{email, password}is answered by the router.- It signs in at the portal and answers in the instance shape:
{status: "signed_in", workspace: "ready" | "pending" | "suspended", user}. useris the workspace user from the cell's/api/auth/me. Without a ready cell it is the account itself:{id, email, user_metadata: {display_name, role}}.- The portal token comes back as both
clapilot_accountandclapilot_session, so apps that keep onlyclapilot_sessionstay signed in. - Wrong credentials answer the portal's
401withcode: "invalid_credentials".
- It signs in at the portal and answers in the instance shape:
- Detection:
GET /api/portal/registrationanswers200 {registrationMode: "open" | "invite_code" | "closed"}only on the hosted portal; a dedicated instance answers401/404. Apps probe it without clearing a stored session. Signed-in screens usehosted_workspace: truefrom/api/profileand/api/app-settings. - Workspace not ready: workspace API calls answer
409withcode: "workspace_pending"or"workspace_suspended"instead of401. - Account: the apps use the portal API on the same origin:
POST /api/portal/registerand/api/portal/password/forgotGET/PATCH /api/portal/me(name)POST /api/portal/password/change(other sessions are signed out, this one stays)/api/portal/team/**(team name, members, roles, invites)
Sign-in entry (both apps). A fresh install opens the sign-in pinned to the hosted product, with no server field: email, password, "Forgot password?" and "Create account".
- Switch at the top right: a secondary icon button (white circle, navy border, no text) that works like a toggle. On the hosted sign-in its building icon ("Sign in to your own instance") opens the instance name screen, the old flow for dedicated instances (
acme→acme.clapilot.com, or a full URL; one full-width "Continue" below the field). On the instance screen and a dedicated sign-in its person icon ("Back to Clapilot sign-in") returns to the hosted sign-in. - Cancel (adding an account, registration and reset sheets) is an X icon button.
- An app already set up for a dedicated instance keeps opening that instance's sign-in, including after signing out.
- Pinned host: the apps pin the constant
web(https://web.clapilot.com). Debug builds of the Apple apps can pin a test router instead with theClapilotHostedProductURLdefault, e.g.xcrun simctl spawn <device> defaults write com.clapilot.app ClapilotHostedProductURL http://localhost:3260. Release builds ignore it. - Sign-out: portal session tokens are stateless, so signing out only removes them from the device. The apps delete both the
clapilot_sessionand theclapilot_accountcookie for the host. Otherwise the router would accept the leftover cookie and sign the user straight back in.
iOS / macOS
- Fresh install: nothing is persisted until sign-in. A reinstalled app can still restore its previous dedicated instance from the shared keychain.
- Adding an account: the account switcher's "Add account" opens the instance name screen, with "Back to Clapilot sign-in" below it. One account per server, as before.
- Sign-in screen: on the hosted portal "Forgot password?" and "Create account" open as native sheets. "Create account" asks for name, email, password, and an invite code when the portal requires one.
- Workspace notice: an account whose workspace is still being prepared or is suspended gets a notice with "Try again" instead of an empty app.
- Settings → Profile: the name is saved on the Clapilot account, the email is read-only, and "Change password" changes the account password.
- Settings → Users: shows the Clapilot team: name, members, roles, and invites with a copyable link when no email went out. Platform admins get a link to the web console.
- Fleet hub entries (subscription usage, DGX cluster) are hidden.
Android
- Sign-in screen: pinned to the hosted product; the switch at the top right shows the instance field (building icon) and hides it again (person icon). Registration and reset close with an X at the top left.
- Hosted portal detected: "Forgot password?" and "Create account" open on the sign-in screen itself. The same workspace notice appears, with "Try again" while the workspace is being prepared.
- Settings: in hosted workspaces the name is saved on the Clapilot account, and "Change password" changes the account password.
- No native team screen yet: "Manage team in the browser" opens
/account.
Production deployment
The hosted product runs from the same release images as every other x86_64 deployment and updates itself:
- Images: pushing a release tag (
X.Y.Z) buildsghcr.io/f1rede/clapilot-release:<tag>-amd64and movesrelease-latest-amd64(workflowpublish-container-release-tags-amd64.yml, self-hosted x86 runner, needs 80 GB free). Hub, portal and router runrelease-latest-amd64withpull_policy: always; the hub hands the same tag to cells (CLAPILOT_FLEET_IMAGE_AMD64) and its hosted pull policy isalways. - Updates: the fleet connector on the host runs the host-global Watchtower (
--label-enable, 60 s). Hub, portal, router, the tunnel container and every cell carrycom.centurylinklabs.watchtower.enable=true, so a new release reaches the stack and all cells without a manual step. Registry credentials come from the hub's fleet secrets (ghcr_username,ghcr_token), which the connector uses fordocker loginand the Watchtower config. - Public entry: a Cloudflare tunnel (
clapilot-hosted-web, remotely managed) mapsweb.clapilot.comtohttp://router:8080; the DNS record is a proxied CNAME to the tunnel. Only the router is public; hub and portal containers are not routed. The router and portal setCLAPILOT_TRUST_PROXY_HEADERS=trueso rate limits use the client address from Cloudflare. - Settings: Portal admin → Settings → General → public URL
https://web.clapilot.com(email links), registration modeinvite_codeuntil open sign-up is decided.
Agent access
Agent access is intentionally not supported. The portal sits outside every team's workspace, no agent runtime runs on a portal deployment, and account or team management must stay a human action.
