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").

StepWhatStatus
0Container hardening: no privileged web container, no-new-privileges, constant-time internal secret checksshipped
1Portal mode: accounts, teams, invite codes, team invites, email verification, password reset, platform admin consoledone
2Cell CLAPILOT_AUTH_MODE=portal: cells trust a router-signed identity and create users on first requestdone
3Router (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 assignmentdone
4Hosted-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 envdone (process-level shell sandbox still open)
4bPlatform 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 portaldone
5Apple/Android clients default to web.clapilot.com and offer Registerdone (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 answer 404, and /login shows 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-info and static assets. Everything else answers 404, including all workspace pages, /api/auth/*, public share links and agent runtime routes. / redirects to /account.
    • In entrypoint.sh and src/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.

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_code mode, redeems one use of an invite code in the same transaction
  • Accepting a team invite:
    • joins the team with the invite's role (admin or member) 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
  • One team per account. portal_team_members.account_id is UNIQUE; 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 code
    • open: anyone can register
    • closed: 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 default app_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 instance users table.
    • The session cookie is clapilot_account, signed like instance sessions but with aud: "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.
  • Passwords: scrypt hashes, like instance users, with a minimum of 10 characters.
  • Session revocation: portal session tokens carry sv, the account's session_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-referrer and 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-XXXX and accepted case- and separator-insensitively.
    • Redemption is an atomic use_count < max_uses update.
    • 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.
  • 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.

Email

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. 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.

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, in invite_code mode, the invite code (prefilled from ?code=).
  • /login: portal sign-in, with a "resend link" action for unverified accounts. ?next= accepts only portal paths.
  • /verify-email?token=, /forgot-password, /reset-password?token=: token flows. Verification and reset sign the account in.
  • /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:
    • team name (the owner can rename it) and workspace status: pending reads "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
  • /portal-admin (platform admins only; 404 for 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.
  • In hosted workspaces the Hub group and the local password page are hidden (sign-in and passwords belong to the Clapilot account). GET /api/app-settings reports hosted_workspace: true there. Dedicated instances are unchanged; the Platform pages answer 404 on them.

The portal's own /account and /portal-admin pages stay for accounts without a ready workspace (and as a fallback). On /account, the admin button opens Settings → Platform once the admin's workspace is ready.

/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), default model, budget per team and budget period.

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_account cookie, or clapilot_session (native apps keep only that one); both hold the same portal token, verified with the portal AUTH_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/DELETE to /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; their API calls get 409 with code: "workspace_pending" or "workspace_suspended". Anonymous page views go to /login, anonymous API calls get 401.
  • Strips client-supplied x-clapilot-portal-identity headers and the clapilot_account and clapilot_session cookies before anything reaches a cell. Removes clapilot_session / clapilot_account from cell Set-Cookie headers.
  • Build assets (/_next/static, root images) go to the upstream serving the user's pages, with a fallback to the other on 404.
  • Proxies streaming responses unbuffered and tunnels WebSocket upgrades (live voice).
  • Env: DATABASE_URL (portal DB), AUTH_SECRET (portal, also decrypts the cell master secret), CLAPILOT_ROUTER_PORTAL_UPSTREAM (default http://clapilot:3000), CLAPILOT_ROUTER_PORT (default 8080), CLAPILOT_TRUST_PROXY_HEADERS. Health check: GET /__router/health. Answers 503 for 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 for CLAPILOT_CELL_ID signed with CLAPILOT_PORTAL_IDENTITY_SECRET. Local clapilot_session cookies are ignored, and /api/auth/login, /api/auth/password-setup and /set-password answer 404. Agent-system tokens, internal secrets and voice-device tokens work as before.
  • POST /api/auth/update saves only the display name (the portal account owns it; the clients save it there first). Email or password changes answer 403 with a localized message, never 401, because native clients treat 401 as 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.
  • /login redirects to CLAPILOT_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/users mutations answer 409 with code: "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):

  1. Moves provisioning cells to active (or failed) from the hub status.
  2. Assigns waiting teams (oldest first) to active, unassigned cells.
  3. 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.
  4. 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-wide env:* 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-edge network (alias <project>-web, bound to 0.0.0.0). Agent, streamer and postgres stay on the cell network. Both networks carry the clapilot.hosted label. Memory and pid limits per service; no-new-privileges everywhere; pull_policy comes from the hub's Fleet → Hosted cells setting (missing for air-gapped or preloaded images).
  • Hub API for the portal: GET (connection test), POST /api/hub/fleet/hosted-cells { overrides }, GET and DELETE /api/hub/fleet/hosted-cells/{instanceId}, PUT /api/hub/fleet/hosted-cells/settings { egressAllow }. The API authenticates only with Authorization: Bearer <portal service key> and answers 404 when 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:

  1. Ensures the edge network exists.
  2. Creates the containers with compose up --no-start.
  3. Rebuilds the CLAPILOT-HOSTED iptables chain (hooked into DOCKER-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 through nsenter. The chain is replaced in a single iptables-restore --noflush transaction and rebuilds run one at a time, so concurrent deploys can never leave a half-built chain that drops replies to LAN clients.
  4. 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. 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:

  1. On the cell host, start docker-compose.hosted-portal.yml (postgres, portal, router on the edge network, cloudflared). Its env is bootstrap only: POSTGRES_PASSWORD, PORTAL_AUTH_SECRET, PORTAL_OPERATOR_EMAIL / PORTAL_OPERATOR_PASSWORD (first platform admin), CLOUDFLARE_TUNNEL_TOKEN.
  2. On the hub, open Fleet → Hosted cells and generate the portal service key. The cell host runs the Fleet connector ≥ 1.5.2.
  3. Sign in to the portal as the bootstrap admin and fill in Portal admin → Settings: public URL, email, Fleet hub (URL and 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}.
    • user is 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_account and clapilot_session, so apps that keep only clapilot_session stay signed in.
    • Wrong credentials answer the portal's 401 with code: "invalid_credentials".
  • Detection: GET /api/portal/registration answers 200 {registrationMode: "open" | "invite_code" | "closed"} only on the hosted portal; a dedicated instance answers 401/404. Apps probe it without clearing a stored session. Signed-in screens use hosted_workspace: true from /api/profile and /api/app-settings.
  • Workspace not ready: workspace API calls answer 409 with code: "workspace_pending" or "workspace_suspended" instead of 401.
  • Account: the apps use the portal API on the same origin:
    • POST /api/portal/register and /api/portal/password/forgot
    • GET/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 the ClapilotHostedProductURL default, 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_session and the clapilot_account cookie 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) builds ghcr.io/f1rede/clapilot-release:<tag>-amd64 and moves release-latest-amd64 (workflow publish-container-release-tags-amd64.yml, self-hosted x86 runner, needs 80 GB free). Hub, portal and router run release-latest-amd64 with pull_policy: always; the hub hands the same tag to cells (CLAPILOT_FLEET_IMAGE_AMD64) and its hosted pull policy is always.
  • 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 carry com.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 for docker login and the Watchtower config.
  • Public entry: a Cloudflare tunnel (clapilot-hosted-web, remotely managed) maps web.clapilot.com to http://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 set CLAPILOT_TRUST_PROXY_HEADERS=true so rate limits use the client address from Cloudflare.
  • Settings: Portal admin → Settings → General → public URL https://web.clapilot.com (email links), registration mode invite_code until 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.