Self-hosted deployment

Deployment guide

This service is deployed on your own servers. This page sets out the prerequisites first; the commands follow below.

Back to home

Deployment overview

Review the operating requirements and product boundaries before changing your server. The complete, version-matched procedure follows in the guide below.

Before you start

This procedure assumes experience operating a command line.

What you will need:

  1. 1A server capable of running Docker (macOS or Linux)
  2. 2At least one Claude Pro or Max account, which you add to the account pool; the members who use your staff do not need one of their own
  3. 3An outbound network route of your own (the “outbound identity” section of the guide below explains why)

Estimated time:

Roughly half an hour if all goes smoothly. The process installs Docker and Node, builds an image of about 1 GB, and starts a database.

A first deployment will very likely take longer than that.

Known limitations

You will encounter these sooner or later, so they are set out before you begin rather than after the deployment is complete.

  1. 01

    It does not start on its own

    Without an instruction it does not run. There is no scheduled or recurring execution; every run requires someone to issue an instruction.

  2. 02

    It does not determine on your behalf that the work is done

    When a staff member's work stops, the interface says only “stopped, please review”. Whether the work is actually finished is not something the system can judge, and it does not assert what it cannot judge.

  3. 03

    You supply the Claude accounts; we neither resell them nor buy them for you

    Whoever runs this system holds the Claude Pro or Max subscriptions (billed monthly) and adds them to the account pool in the admin area. Staff draw on the pool while they work; members never need an account of their own and never see which one is in use. When the whole pool is exhausted, staff cannot continue.

  4. 04

    Sign-in state is isolated by member; outbound routes follow runtime policy

    Each member has a private runtime: files and conversations are not visible to anyone else. Sign-in state is held centrally in the account pool, and one account serves only one runtime at a time. A runtime policy may still share one outbound route. Configure separate policies and routes when staff members need unrelated public identities.

  5. 05

    Maintenance interrupts staff members while they are working

    The conversation record is preserved in full and the conversation can be continued, but that particular run stops where it is.

  6. 06

    Only self-hosted deployment is supported

    One deployment serves one team. Members share the published list of skills and infrastructure policy, but each gets an isolated runtime and sign-in state.

Version-matched documentation

Step-by-step guide

English | Chinese

Publish expert-built Claude Code and Codex projects as reusable Staff for your team.

www.curvepulse.com

The product, repository, private packages, and images are standardizing on CurvePulse. Existing qruiq identifiers already stored in databases, persistent volumes, configuration, or protocol payloads remain compatible; rebranding never requires a destructive data migration. Until that migration is complete, some commands below still use legacy names and should be copied exactly as shown.

What CurvePulse provides

A mature Claude Code or Codex project is more than a prompt. It may contain its project structure, CLAUDE.md or AGENTS.md, skills, rules, scripts, and a workflow refined through repeated expert use. CurvePulse imports that project from a ZIP or Git snapshot and lets an expert publish a reviewed, immutable copy as a Staff template.

A member who chooses the Staff receives a member-owned runtime and an independent, writable copy of the project, backed by an explicitly resumable Claude Code or Codex conversation. Project files, working memory, CLI configuration, and the conversation identity persist so the same work can continue after a container is replaced.

The cross-member boundary is the member runtime. Different members have separate containers, workspaces, CLI configuration, and sign-in state. One member may create several Staff instances; each instance still has its own project and conversation. Instances belonging to that member may share the member's CLI sign-in state, but not their project memory. Outbound identity is controlled by the runtime policy, so container isolation does not imply a unique public address.

CurvePulse is currently single-server, self-hosted software. It uses your own Claude Code or Codex account and outbound route. If a route is ineffective, the administration UI reports what it measured instead of presenting the intended configuration as fact.


Prerequisites

RequirementWhy it is needed
Docker (Desktop or Engine)Each member runtime uses two containers, and cross-member isolation is enforced by the operating system
Your own SOCKS5 or HTTP routeThis supplies the runtime policy's online identity. CurvePulse does not resell routes; it measures the real public address when a route is added
A Claude Pro or Max accountThe currently verified Claude Code Staff uses your subscription quota; Codex remains gated
Node.js 20+ and Yarn 1.xThe app and gateway are Node processes

The interface can be started without an outbound route, but a newly created Agent will immediately report that its route is ineffective. That is expected behavior, not a service failure.


Installation

Allow about 15 minutes. Roughly 10 minutes of that is normally spent building runtime images.

1. Start MySQL

bash
docker run -d --name qs-mysql \
  -e MYSQL_ROOT_PASSWORD=devroot \
  -e MYSQL_DATABASE=qruiq_staffs \
  -e MYSQL_USER=qruiq -e MYSQL_PASSWORD=devpass \
  -p 127.0.0.1:3310:3306 \
  mysql:8.4

The service binds the MySQL listener only to 127.0.0.1 intentionally. The database should not be exposed to the local network.

2. Build the four runtime images

bash
docker build -t curvepulse/net:dev              -t qruiq-staffs/net:dev              images/net
docker build -t curvepulse/agent-base:dev       -t qruiq-staffs/agent-base:dev       images/agent-base
docker build -t curvepulse/agent-claude-code:dev -t qruiq-staffs/agent-claude-code:dev images/agent-claude-code
docker build -t curvepulse/agent-codex:dev       -t qruiq-staffs/agent-codex:dev       images/agent-codex

Both Agent CLI images inherit from agent-base, so build the base first. The commands temporarily write both the new curvepulse/* tags and the legacy qruiq-staffs/* compatibility tags. Existing deployments can therefore rebuild records that still refer to an old tag; do not remove those tags until all persisted image references have migrated. agent-codex installs and verifies the exact @openai/[email protected] artifact. Its CLI/container contract and dedicated Staff adapter are implemented, without reusing Claude arguments, state, or archive paths. The complete TUI lifecycle, hostile project configuration, empty prompts, hot persona updates, and status-event gates have not all passed, so the registry keeps it unverified and unavailable for instance creation.

For cloud deployment, check the target architecture. Docker Desktop may build for arm64, while a cloud server is commonly amd64. Native modules such as node-pty cannot cross that boundary. Build with the target explicitly when needed:

bash
docker buildx build --platform linux/amd64

3. Install dependencies and generate the Prisma client

bash
(cd app && yarn install)
(cd gateway && yarn install)
./scripts/relink.sh
(cd app && yarn db:push)

Do not skip ./scripts/relink.sh; the first troubleshooting item explains why. The final command creates the schema and generates db-client/generated.

3b. Upgrade an existing installation

db:push above is intended for an empty database. If the database already has data, run the SQL files in app/prisma/migrations-manual/ in filename date order:

bash
docker exec -i qs-mysql mysql -uroot -pdevroot qruiq_staffs \
  < app/prisma/migrations-manual/2026-08-05-user-role-index.sql
docker exec -i qs-mysql mysql -uroot -pdevroot qruiq_staffs \
  < app/prisma/migrations-manual/2026-08-09-proxy-secret-version.sql
docker exec -i qs-mysql mysql -uroot -pdevroot qruiq_staffs \
  < app/prisma/migrations-manual/2026-08-09-proxy-revision.sql
docker exec -i qs-mysql mysql -uroot -pdevroot qruiq_staffs \
  < app/prisma/migrations-manual/2026-08-11-user-runtimes.sql
docker exec -i qs-mysql mysql -uroot -pdevroot qruiq_staffs \
  < app/prisma/migrations-manual/2026-08-11-project-imports.sql
docker exec -i qs-mysql mysql -uroot -pdevroot qruiq_staffs \
  < app/prisma/migrations-manual/2026-08-12-workspace-project-assets.sql
docker exec -i qs-mysql mysql -uroot -pdevroot qruiq_staffs \
  < app/prisma/migrations-manual/2026-08-12-instance-start-idempotency.sql
docker exec -i qs-mysql mysql -uroot -pdevroot qruiq_staffs \
  < app/prisma/migrations-manual/2026-09-02-skills-additive.sql
(cd app && ./node_modules/.bin/prisma generate)

Read the header of every SQL file before running it. Each header explains the change and whether db:push is a safe substitute. Two migrations explicitly cannot be replaced by db:push, because pushing would drop a table.

2026-09-02: job roles become skills (this one has its own order)

Three SQL files, and none may be replaced by `db:push` (k8s/{,prod/}db-push-job.yaml carries an initContainer gate: Skill table missing → fail; JobRole table still present → fail with "run phase D first"; WorkspaceCredential / MemberCredential missing, Skill.env still present or Skill.requiredEnv missing → fail with "run the credentials migration first"; StaffCredential / HireTicket missing → fail with "run the 2026-09-05 migration first" (added on 2026-09-05); app/test/k8s-db-push-pin.test.js pins the image tag to deployment.yaml):

  • 2026-09-02-skills-additive.sql (Phase A) is additive: it creates the

Skill, StaffSkill and WorkspaceRuntimeDefault tables, backfills one skill per job role (same id) and one skill link per staff member, relaxes Staff.jobRoleId to nullable, and, when exactly one non-retired PER_USER Claude runtime profile exists, records it as the workspace default. Safe to run more than once. Run it before deploying the new code (already applied to dev and prod on 2026-09-02, see k8s/deployments/skills-20260902.md).

  • 2026-09-02-skills-phase-D.sql (Phase D, part of this release) is

destructive: it drops Staff.jobRoleId (foreign key, index, column) and the JobRole table. Re-entrant, fail-closed; code first, SQL second (the opposite of A): run it only after the code that no longer reads or writes JobRole is live.

  • 2026-09-02-skill-credentials.sql (two-layer credentials, after

Phase D) creates the WorkspaceCredential and MemberCredential tables (foreign keys to User), adds Skill.requiredEnv and drops `Skill.env`. Re-entrant, fail-closed; its gate: if any Skill row has an env that is not NULL and not {}, nothing is written (enter those values as workspace credentials first, set that row's env to NULL, then rerun; both databases currently have zero Skill rows). Every run writes one migration.credentials.A audit row (counts only). Same maintenance window as the code that reads `requiredEnv`: the old code reads only env and fails with P2022 once the column is gone; the new code reads requiredEnv and the two tables and fails with P2021 while they are missing. prisma migrate diff is empty only after all three have run.

Release order. Production runs one Pod with Recreate; the App and the Gateway live in the same template, so there is no "Gateway first, then App" step:

  1. Phase A has already run on both databases (migration.skills.A audit rows).

WorkspaceRuntimeDefault must hold its 'default' row — if it does not, insert one before deploying: INSERT INTO WorkspaceRuntimeDefault VALUES ('default', '<profile id>', UTC_TIMESTAMP(3), UTC_TIMESTAMP(3)). On an installation that only has a LEGACY_SHARED profile, this INSERT is the administrator's explicit acknowledgement; record it in the release notes (prod acknowledged it on 2026-09-02). The Phase D gate requires this row.

  1. Apply one `kubectl apply` that raises both the app and the gateway tags

to the version that no longer reads or writes JobRole.

  1. Back up and record the marker (the Phase D gate reads it: the sha must be

64 hex characters and less than 24 hours old):

bash
mysqldump --single-transaction <db> JobRole Staff > backup-before-skills-D.sql
sha256sum backup-before-skills-D.sql
mysql <db> -e "INSERT INTO AuditLog (id, action, detail, createdAt) VALUES (CONCAT('mig_', REPLACE(UUID(),'-','')), \
  'migration.skills.D2-backup', JSON_OBJECT('dumpSha256', '<sha256>', 'tables', 'JobRole,Staff'), UTC_TIMESTAMP(3))"
  1. Run Phase D (production uses k8s/prod/migrate-job.yaml; the Job log must

show the sha line the script prints — compare it with the sha256sum above). A second run must be a no-op (droppedTable / droppedColumn are 0; only one more migration.skills.D audit row).

  1. In the same window, run the credentials file

(2026-09-02-skill-credentials.sql; production uses migrate-job.yaml as well). A second run must be a no-op (only one more migration.credentials.A audit row). If any Skill row still has a non-empty env, the script stops and names it: enter those values on the Workspace credentials page, set that row's env to NULL, then rerun.

  1. Acceptance: prisma migrate diff is empty and the prod.sh preflight

passes (it also checks the two credential tables, that Skill.env is gone, that Skill.requiredEnv exists, and the two foreign-key rules).

Replay the whole thing in a scratch database first (real dump → A ×2 → workspace default → Phase D gate negatives → D2-backup marker → D ×2 → credentials SQL ×2 → empty migrate diff against the new schema):

bash
DATABASE_URL="mysql://root:<pw>@127.0.0.1:3311/curvepulse_skills_mig_test" MYSQL_DOCKER_CONTAINER=curvepulse-ui-audit-db \
  REPLAY_DUMP=~/curvepulse-backups/2026-09-02/curvepulse-full-before-skills-A.sql node scripts/db/skills-migration-replay.mjs

Rollback: there is no rollback SQL. Rolling back means restoring JobRole and Staff from the dump taken before Phase D (that is why the backup gate exists). Rolling the Pod back to the previous template is no longer a path either: the old code needs the JobRole table and the /api/job-roles* / roles:* aliases, all of which are gone, and the historical job-role / staff data has been wiped.

2026-09-05: a third credential layer, "this staff member only"
  • 2026-09-05-staff-credentials.sql creates StaffCredential (a key given to

one staff member) and HireTicket (the one-shot ticket the hire wizard uses), with four foreign keys, all CASCADE. It is purely additive: no backfill, no column changed, re-entrant and fail-closed. Its gates: Staff, User and AuditLog exist; both earlier credential tables exist with Skill.requiredEnv present and Skill.env gone (that is, the 2026-09-02 file has run); and if a table already exists its shape must match byte for byte, otherwise the script SIGNALs without writing a single byte. Every run writes one migration.staffCredential.A audit row (counts only). It may not be replaced by `db:push` either: pushing from an older image would drop these tables and the ciphertext in them, and values are write-only — nobody can type them again.

Release order (only one direction is safe):

  1. Run the SQL first, then move the images. In between, the old code cannot

see the two new tables and behaves exactly as it does today. The other way round, the new app and the new gateway SELECT tables that do not exist yet: every hire 500s and every start is refused.

  1. cd app && ./node_modules/.bin/prisma generate (not npx — different

version).

  1. Ship the app and the gateway images together. Unlike the 2026-09-03

migration, the gateway is not a pre-migration no-op here: it queries StaffCredential itself, because it is the one that decrypts and injects the third layer at start.

  1. Acceptance: prisma migrate diff is empty and the prod.sh preflight passes

(it checks the two new tables and their four foreign-key rules too).

Rollback: purely additive, so there is no rollback SQL — move the images back and leave the tables in place. ⚠️ But once one staff member has been hired with a "this staff member only" key, the gateway must not be rolled back on its own: an older gateway resolves only the member and workspace layers and would refuse that staff member's start with CREDENTIALS_MISSING. Roll the app back on its own instead: the new gateway keeps serving the staff who already exist, while the app stops creating new staff-scoped keys.

The two 2026-08-12 migrations must run before generating the shared Prisma client or starting the new processes. The first adds workspace-owned project assets; the second adds six nullable request-scoped task-start fields (request and payload identity, first-message delivery ledger, and lease) plus the full (ownerUserId, startRequestId) unique index. startRequestId uses the case-sensitive ascii_bin collation and the index may not use prefix lengths. Both are additive and leave old instances unchanged. Use the release order database migrations → `prisma generate` → new Gateway → new App.

Freeze outbound-route edits before applying the two 2026-08-09 migrations. They are rerunnable, fail-closed additive migrations, but an old App does not increment revision and can still bypass the new CAS while both versions run. The fixed release order is database migrations → `prisma generate` → new Gateway → new App. The shared Prisma client also makes Gateway proxy includes read the new columns, so upgrading only the App or starting either new process before the migration is unsafe. Keep both additive columns during a code rollback.

Back up two independent recovery assets before the upgrade: the database dump contains ciphertext and business state, while the complete encryption keyring belongs in a separate secret backup. A dump without its corresponding keyring cannot recover route passwords or Agent tokens. Never put the keyring inside the database dump or repository.

Some SQL files dated before 2026-08-05 contain non-ASCII column aliases, while the MySQL command-line client in the runtime defaults to latin1. If one of those files fails on its first statement with ERROR 1064, add:

bash
--default-character-set=utf8mb4

The 2026-08-05 migration already runs SET NAMES utf8mb4; do not add the option for that file. It is also safe to run more than once.

The final acceptance condition is zero difference between the schema file and the live database:

bash
cd app && ./node_modules/.bin/prisma migrate diff \
  --from-schema-datasource prisma/schema.prisma \
  --to-schema-datamodel   prisma/schema.prisma --script
# Expected output: -- This is an empty migration.

Any other output lists the DDL that has not been applied. Use the Prisma binary already installed in this repository. Do not use npx, which may download a newer major version and run it against this database. The acceptance script checks the same condition.

4. Configure the services

bash
cp app/.env.example     app/.env
cp gateway/.env.example gateway/.env

Every variable in those files includes a comment describing what happens if it is omitted. Generate these three values yourself:

bash
openssl rand -base64 32   # app/.env: NEXTAUTH_SECRET
openssl rand -hex 32      # both files: GATEWAY_INTERNAL_KEY; use the same value
openssl rand -hex 32      # both files: SECRET_ENC_KEY; use the same value

SECRET_ENC_KEY must be exactly 32 bytes of hexadecimal data: 64 characters. The current implementation fails closed: Gateway refuses to start with a missing or malformed keyring, and App operations that write or probe protected credentials fail instead of silently treating unreadable material as an empty password.

SECRET_ENC_KEY remains the backwards-compatible single-key form. During a rotation, App and Gateway must receive the exact same complete keyring, for example (all values below are placeholders):

dotenv
SECRET_ENC_ACTIVE_KID=key-2026-08
SECRET_ENC_KEYS={"key-2026-08":"<new-64-hex>","legacy":"<old-64-hex>"}
SECRET_ENC_KEY=<old-64-hex>

SECRET_ENC_ACTIVE_KID selects the key for new writes; SECRET_ENC_KEYS retains both active and historical read keys. On the first move away from the single-key form, the old key must remain addressable under the literal kid `legacy`. Single-key deployments have already written qssec:v1:legacy:...; the alias is required even when identical key material also appears under another kid. Keep SECRET_ENC_KEY during the transition so the shared parser and any not-yet-retired legacy configuration can still find that key.

Keyring parity has two independent fail-closed layers. When deploying through prod.sh start/restart, the script compares the complete canonical keyring parsed for App and Gateway before starting the services, including the active kid and every historical read key. Even when the script is bypassed with direct npm start / npm run dev, App performs a runtime handshake with the live Gateway for every new non-null proxy ciphertext it prepares to persist for a create, replacement, or rewrap. The handshake is protected by GATEWAY_INTERNAL_KEY, refuses redirects, and compares the same canonical fingerprint. Every request uses a fresh random challenge; Gateway echoes it only on a match together with a fixed magic value and protocol version, and App strictly requires HTTP 200 plus exactly those four acknowledgement fields instead of accepting a generic {ok:true}. When a live proxy probe runs, App performs a second handshake after the probe and immediately before the database transaction, narrowing the window in which Gateway could change rings while App waits. A mismatch, an older Gateway without the handshake, or an unreachable Gateway rejects the operation before any database write or audit; a failed first handshake also prevents the probe. Upgrade Gateway before App for this reason. Explicit clear/null and true keep paths that generate no new ciphertext remain available for configuration recovery; they neither bypass a new-ciphertext check nor prove parity or rotation completion.

The format rollout is only one-way compatible. Only the offline migration tool from the same build may read historical untagged base64; online App and Gateway processes reject that format because it has no authenticated purpose or row binding. Old binaries, in turn, cannot read new qssec:v1:* values. Freeze route edits, stop the old services, and switch atomically in this order: database migrations → generate → offline rewrap → new Gateway → new App. After the offline tool writes the first v1 ciphertext, do not roll back to an old binary. Backing out a key rotation also requires more than selecting the old kid as active and restarting: after that switch, rows written by the former-active new key immediately become old-kid rows in inventory. Keep the new code and both read keys, freeze every secret write, and keep the services offline while selecting the old kid as active. Offline, run a full rewrap apply across ProxyEndpoint.secretEnc and Agent.agentTokenEnc, then repeat dry-run until its final pending count is zero. Only then start the services, and retain both keys throughout the procedure.

Remove an old key only after a complete inventory, authenticated decrypt, and rewrap has reduced old-kid, legacy-format, and unreadable counts to zero in both locations:

  • ProxyEndpoint.secretEnc
  • Agent.agentTokenEnc

Editing a few routes does not complete a rotation; Agent tokens use the same at-rest key. Run the following from the same checkout with the complete keyring; the final dry-run must exit 0:

bash
cd gateway
node --env-file=.env scripts/rewrap-secrets.mjs dry-run
node --env-file=.env scripts/rewrap-secrets.mjs apply
node --env-file=.env scripts/rewrap-secrets.mjs dry-run

prod.sh start/restart runs the same inventory as a hard gate before daemonizing or stopping the old processes. Do not treat natural edits as proof of completion; keep the old key and do not start the new services until the final inventory is clean.

Google sign-in must be configured before step 6. It is the normal sign-in method, so without it nobody can enter, including the administrator. The service and sign-in dialog still start when the two Google variables are empty, but that screen can only explain that configuration is incomplete.

5. Start CurvePulse

bash
./scripts/prod.sh build
./scripts/prod.sh start

start returns after a few seconds because the services run in the background. Closing the shell does not stop them. Inspect status and logs with:

bash
./scripts/prod.sh status
./scripts/prod.sh logs app -f

6. Configure Google sign-in before the first visit

Google is the normal sign-in method. Complete this step before anyone tries to enter the service.

  1. Open Google Cloud Console → Credentials

and create an OAuth client ID for a Web application.

  1. Add this authorized redirect URI:
text
http://localhost:3000/api/auth/callback/google

Replace the origin with the value of NEXTAUTH_URL. The first sign-in screen also calculates and displays the exact redirect address, so it can be copied instead of assembled by hand.

  1. Put the client ID and secret in app/.env, then restart:
bash
./scripts/prod.sh restart

7. First sign-in

Open http://localhost:3000, choose “Get started”, then “Continue with Google”.

The first person who signs in becomes the OWNER. That promotion path closes permanently once the owner exists. No setup code or later account-linking step is required: an unknown account is registered, and a known account signs in.

Later users arrive in a pending state. The OWNER approves them on the Members page.

An older release included a ./scripts/prod.sh setup-token bootstrap path. It created the first OWNER from a one-time code. That path was removed on 2026-08-05 because the resulting account had a user row but no Google account link. The administrator could then configure Google, try it for the first time, and be rejected with OAuthAccountNotLinked by their own service.

The tradeoff is explicit: a fresh installation must configure Google before the product can be entered for the first time.


Environment nodes (running runtimes on another machine)

An environment in the admin UI is a machine that enrolled itself (the code calls it a Cluster). The platform itself is one (local, env#0): the Pod runs an env-agent container alongside the gateway, it enrolls on its own after startup, and the OWNER approves it under /admin/clusters and allows dispatch before the first member can hire. Every other machine joins the same way:

  1. Issue a key: /admin/clusters → "Add an environment" creates a card

(set its region) → "Issue enrollment key" gives a one-time enroll key (valid for one hour). The install command on the card already carries the hub address and the image reference.

  1. Run `env-node/install.sh` on that machine. The script does three

things: checks prerequisites (docker ≥ 27, compose v2) → writes .env (hub address, image reference, enroll key; no platform secret of any kind) → docker compose up -d starts the dind + env-agent stack; it then follows the enrollment log and wipes the key once enrollment succeeds.

  1. Approve and allow dispatch: once the machine connects, the card reads

"Waiting for approval". Approval requires a region; after approval the node connects and runs one capability probe, and only when all five hard items pass can "Allow dispatch" be turned on. Runtime policies only pick environments whose dispatch is on; "Drain" blocks new placements without touching dispatch; "Pause" and re-enrollment turn dispatch off again until you switch it back on.

Which environment a new staff member lands on is decided by the runtime policies under /admin/runtime-profiles (checked environments, priority, route). Members never see the word "environment": hiring can at most report "runtime location not configured / temporarily unavailable".

Verifying locally: the single-machine container topology is gone. Local = unit tests + the env-node stack from `env-node/compose.yaml` (point ENV0_DOCKER_HOST at its dind; scripts/env-node-smoke.mjs runs one real up → observe → destroy). Release order, the wipe, and the acceptance checklist live in the 2026-09 environment-cutover note under k8s/deployments/.


Using CurvePulse

There are two paths for two different audiences. Immediately after installation you are the administrator and follow path A. Invited members follow path B.

A. Administrator: publish an expert project as Staff

  1. Open Outbound routes, add your SOCKS5 or HTTP route, and run its check.

CurvePulse opens a disposable check through that route and reports both the measured address and the server's direct public address. If they match, the route did not take over the traffic and the UI reports that clearly.

  1. Environments (/admin/clusters): confirm that local is approved and

has dispatch allowed; to put runtimes on another machine, add one as described under "Environment nodes" above. An administrator runtime (/admin/agents/new, OWNER only) hosts no staff; it exists for maintenance and account-pool sign-in.

  1. On Skill setup (/admin/skills), choose the default runtime policy

at the top of the page, then open Runtime policies (/admin/runtime-profiles) and check the environments it may land on: every new staff member starts under it. Skills are not bound to a runtime, and nobody can hire until a default exists and that policy has at least one environment checked. Every member has one private runtime (only PER_USER exists since the 2026-09 cutover).

  1. Upload a ZIP or import a one-time snapshot from a public GitHub or GitLab

repository. Known sign-in/session files and common credential filenames are removed, but you must still review ordinary files, hooks, and scripts.

  1. Create the skill: New skill asks only for a name, creates a private

draft, and opens the skill's detail page. Fill in the rest there: summary, limits, instructions (read only by the staff member, always in effect, at most 24 KB per skill), permission level, optionally the credentials it needs (name plus a one-line description; the value is not stored on the skill) and the imported project as its starting project. Edits collect in a save bar at the bottom of the page and are written in one go when you select “Save changes”; listing, unlisting and retiring wait until the draft is saved, and leaving the page asks first. A skill backed by a personal project is saved privately first. When two published skills both carry a starting project, the list shows a warning that they cannot be hired together. Saving is not blocked; you see it before a member does.

  1. Workspace credentials (/admin/credentials): give the names the skills

declare their values; every member's staff can use them. Values are stored encrypted and are write-only: after saving, not even an administrator can read one back. The page shows only "set · when · by whom · which skills need it"; to change a value, type it again. A name the workspace does not provide is requested from the member at hiring time. What the member enters there goes to the one staff member being hired by default; they can widen it to "all the staff you hire" (their own credentials) instead. Both override the workspace value of the same name — the narrowest one wins. An administrator can see which names a staff member is missing but cannot write another member's staff-scoped key (trying is a 404). Removing one does not affect staff on duty, but no skill that needs it can be hired until somebody provides it again.

  1. Confirm publication from the list. CurvePulse verifies the content again and

promotes it into an immutable workspace-owned template before members can see it.

> This publication step is easy to miss. If it is not completed, members see an empty > skill catalog even though the administrator may consider setup done.

B. Member: hire first, pick skills along the way (/staff -> /staff/new)

The entry point is My staff, not the skill market. Members land on /staff after signing in. Before anyone has been hired, that screen reads "You haven't hired anyone yet", with a primary "Hire staff" button and a secondary link to browse the skill market. Members cannot see environments, runtimes, outbound routes, other members' instances, or their projects.

  1. "Hire staff" opens the hiring page (/staff/new), which has three steps:
  1. Pick skills. Search and filter by level on the left, then select 1 to

8 skills. They must share the same level: a mixed selection names the pair that disagrees and offers a one-press fix ("keep only the N that ask you first", spelling out which ones would be dropped). The right-hand panel follows along: a name (leave it empty and one is chosen for you; rename them later on their page), the order of the selected skills (that order is the order of their instructions), and one sentence about how far this staff member may go while working -- a statement, not a control.

  1. Get them set up. This step appears only when credentials are missing or a

starting project has to be chosen; otherwise it is skipped entirely. Missing credentials get one password field each (with the description the skill declared) plus a "who gets this key" choice, and are saved when the field loses focus, with a checkmark. The choice defaults to the one staff member being hired (the saved line then says you can change it on their page); the other option is "all the staff you hire", which saves to My credentials and links to /credentials. There is no workspace-wide option here — writing a workspace credential is an administrator's act on /admin/credentials, not a side effect of hiring. A "Change" affordance clears a row so it can be typed again, because a saved value can never be read back. The starting project is chosen here too: one brought by a selected skill, your own ZIP or Git snapshot, or an empty start -- with a note that it cannot be changed after hiring.

  1. Put them to work. A read-only summary (skills, what the level means,

where the starting project comes from), one sentence about the cost ("their work runs on your own quota; skills are set at hiring and can't be changed afterwards"), and the "Hire" button (market.assign.go). The level you were shown is sent back with the request: if an administrator changed it in the meantime, you are taken back to step 1 to look again. Missing credentials send you back to step 2 the same way, so a staff member who could never start is never created.

  1. The skill market (`/skills`) is for browsing only. Each card shows a

name, what the level means, one sentence, the credentials it needs (with the ones you are missing marked), whether it brings a starting project, and how many of *your* staff already carry it. The name opens the skill's own page: what it does, what it cannot do, how far it may go, which credentials it needs (fillable right there), and its starting project. "Hire with this skill", on the card and on the skill page, simply carries that skill over to the hiring page -- the market has no checkboxes and opens no hiring dialog.

  1. My credentials (/credentials): see which ones you have set, which

skills need them, and which ones the administrator provides in the workspace (read-only rows). You can replace or remove your own. A value can never be read back once saved; a new value applies to staff hired afterwards, not to staff on duty. If you also gave that name to individual staff members, the row carries one extra line — "also set for N staff members", a count only, with no staff names and no values — and a name that exists only at staff scope gets its own row instead of pretending it is unset. To change one of those, go to that staff member's own page ("Keys they use").

  1. On first use of the default runtime policy, CurvePulse provisions a

member-owned container and persistent volumes. If the provider is not signed in, complete sign-in once inside that member runtime.

  1. CurvePulse copies the template into an independent writable project, creates

the conversation, and opens the staff member's page: you type the first task there, in the conversation (integrations may pass it at hiring time instead). The staff member's name does not include skill names ("Staff #N" unless you named them; integrations that pass a product get "{product} staff"). At that moment the Gateway resolves the credentials by name (the one set for this staff member first, then yours, then the workspace's — the narrowest wins), decrypts them in place and hands them to the staff member's session; values never touch disk or any response. The ciphertext is bound to that staff member's identity, so a row moved onto somebody else does not decrypt: the start is refused, never served the wrong value.

  1. Their page says which skills they carry. One line under the subtitle

lists the skill names in hiring order, each linking to its own skill page, followed by "Set at hiring, and can't be changed afterwards" and a "Hire another like this" link that carries the same skills, in the same order, to the hiring page. That line stays on the page while they are being off-boarded and after they are gone: once you have read the transcript, "hire another one just like them" is the most common next step.

  1. Closing the page does not delete the project or conversation. Returning

reconnects to the same instance, and persisted state supports resume after a container replacement. A staff member's set of skills is frozen once hired; to change the combination, hire another staff member.

How skill updates affect staff

  • Instructions and names update live. Editing a skill's instructions, name

or summary reaches the staff on duty who carry it on the next reconciliation pass: the rules file inside each instance is rewritten without rebuilding the environment or disconnecting the conversation. Editing instructions should not kill active work.

  • Credentials and the permission level apply on the next start. A running

session cannot change the credential values or the level it was started with; the form says so. A replaced credential value, or a name newly declared by a skill, takes effect the next time that staff member starts work. The same holds for replacing a staff-scoped key under "Keys they use": restarting a session that is still alive changes nothing (the value is materialised and thrown away), so that flow ends the session first.

  • The starting project does not follow. It is an immutable publication

snapshot. Updating it requires a new import and publication and never silently rewrites existing instances.

  • Edits that collide with a combination on duty are refused (409). Growing

the instructions past the composed limit, or changing the level so that a combination on duty is no longer uniform: the save names the affected staff instead of letting the gateway fail silently on the next pass. Off-board those staff first, or choose a value that does not collide. Staff already off-boarded are outside this check. Changing a skill's required credentials does not collide with staff on duty: whether one is missing is decided at the next start.


Troubleshooting

1. Cannot find module '@curvepulse/db-client'

Yarn 1 copies file: dependencies instead of linking them. After every yarn install, the two local packages can therefore become stale copies.

Run:

bash
./scripts/relink.sh

./scripts/prod.sh build checks this prerequisite automatically.

2. An Agent remains FAILED or stuck on Starting

First inspect the gateway log:

bash
./scripts/prod.sh logs gateway | tail -50

The most common causes are:

  • The outbound route is unreachable or its credentials are wrong. The UI

reports repeated outbound failures. Re-run the measured-address check on the Outbound routes page.

  • The current keyring does not contain the key that encrypted the password.

Decryption fails closed, so the tunnel receives no credentials. Restore the matching keyring first and verify that App and Gateway use identical configuration. Do not blindly re-enter the password: that overwrites the only old ciphertext and advances secretVersion. Re-entry and Agent-token rotation are disruptive recovery steps only when the old key is truly lost.

  • A runtime image was not built. If curvepulse/net:dev does not exist,

return to installation step 2.

3. A new Agent immediately reports an ineffective outbound route

This is not a CurvePulse failure. The measurement found that the Agent is using the server's direct public address rather than the address claimed by the configured route. The route is either ineffective or entered incorrectly.

4. An address is already in use, or a service does not start

bash
./scripts/prod.sh status
./scripts/prod.sh stop

status identifies the process occupying a required listener.

5. Running two installations on one server

Set QS_NAMESPACE for at least one installation. Without it, both installations operate on the same runtime names. By default those names are derived only from the Agent slug, so identical slugs in two databases point at the same runtime. This has caused one installation to delete active work belonging to another.

bash
# gateway/.env for staging
QS_NAMESPACE="stg"

Recovering access

If you can run a command on the server, you can recover access with one command.

bash
./scripts/prod.sh rescue-login
./scripts/prod.sh rescue-login --email [email protected]

The command prints a one-time code and an address. Open the address and paste the code to return to that existing account.

When recovery is needed

The original incident that required this command looked like this:

/login?...&error=OAuthAccountNotLinked

This email address is already registered another way.

The sign-in framework found an existing account with the same email address but no link to the supplied Google identity. It correctly refused the sign-in, but left no supported route back into the service. The removed bootstrap-code flow was one way to create that state: it created a user without ever using Google.

That designed-in path is gone, but a mismatch can still occur when:

  • Someone changes their own sign-in email to another person's Gmail address.

The first Google sign-in then collides with the email while no account link exists. The Members page cannot change somebody else's email; it only manages roles and approval.

  • Several people try to become the first user on a fresh installation at once.

The database statement can still deadlock, but the sign-in flow now retries and never throws the OWNER promotion failure back into the account-linking step. Everybody can still enter; at worst, one person does not become the administrator. The server log prints the corrective SQL if required.

  • Google recreates an account. The email remains the same while Google's user

identifier changes.

Other cases include revoked Google credentials, a changed NEXTAUTH_URL whose redirect no longer matches, and expiration of the only administrator session. They share one property: repairing the sign-in method normally requires being signed in first.

Why the recovery command is safe

The authentication factor is the ability to execute a command on the server. Anyone with that access can already read the database, modify code, and restart the service. The recovery command does not expand that authority; it turns a manual database operation into a supported, audited, expiring workflow.

It is stricter than the removed bootstrap code in several ways:

PropertyBootstrap setup-token (removed)Recovery rescue-login
When it existsGenerated whenever the service startsOnly after an operator runs the command
Primary lockDisabled once any OWNER existsNo primary lock; see the controls below
LifetimeUntil it is consumed15 minutes, measured by time rather than file presence
UsesOneOne
File permissions06000600
AuthorityCreated the first OWNERCreates a session only for an existing account

The last row is the important one: recovery does not elevate privileges. It does not create a user, change a role, or approve an account. Recovering an unapproved member still leads to the pending-approval page. The command answers “how do you return to your account?”, not “what may that account do?”.

Every recovery records an auth.rescue_login audit event. Its session expires after 12 hours and does not renew automatically as a normal sign-in does. The next day, the operator must prove server access again. It is still a database session, so an OWNER can invalidate it immediately.

First action after recovery

Open Settings and link the account to Google.

That link is the durable way back in. It records the relationship between this CurvePulse account and the Google identity. The recovery command only gets the account through the door; it does not repair the normal sign-in method.


Other repository documents

FileAudience
PRODUCT.mdProduct decisions: scope, exclusions, and the reason behind each acceptance condition
NOTES.mdRecorded operational failures. Search it before changing related code
DELIVERY.mdDelivery notes and the acceptance-script checklist

Acceptance commands

bash
./scripts/prod.sh status
./scripts/acceptance.sh
./scripts/runtime-owner-docker-gate.sh
./scripts/netns-security-docker-gate.sh
./scripts/codex-runtime-docker-gate.sh
./scripts/leak-audit.sh <agent-runtime> <network-runtime>
./scripts/authz-matrix.sh

The single-machine container topology behind prod.sh was retired with the 2026-09 environment cutover (the script and its rescue-login stay): local = unit tests + the compose env-node stack, see "Environment nodes" above.

./scripts/acceptance.sh requires ACCEPT_COOKIE. The leak audit observes what can be learned from inside the two supplied runtimes. The authorization matrix includes attempted privilege violations.

runtime-owner-docker-gate.sh creates only a randomly labelled, temporary agent-base container with the Gateway's production security settings: CapDrop=ALL, only SETUID/SETGID/CHOWN during entry, and no-new-privileges. It verifies that PID 1, the supervisor, and a real supervisor child are non-root with zero effective, permitted, and ambient capabilities. It also exercises read, write, chmod, rename, restart persistence, and graceful SIGTERM shutdown across both the project and user configuration bind roots. Cleanup targets only the immutable ID or unique label created by that invocation; existing Agent runtimes are never touched.

netns-security-docker-gate.sh creates one randomly labelled, disposable curvepulse/net:dev container with the Gateway's production HostConfig. It verifies CapDrop=ALL, NET_ADMIN as the sole added capability, no-new-privileges, the built-in seccomp profile, and the /dev/net/tun mapping. After startup it proves that PID 1 has only capability 0x1000 in its effective and permitted sets, an empty ambient set, NoNewPrivs=1, and Seccomp=2; it also requires tun0 and a QSNET_OUT chain ending in an unconditional DROP. The gate then checks graceful SIGTERM shutdown. Both success and failure paths remove and re-query only the immutable ID and unique label from that invocation, so existing Ada and Grace runtimes are never read or changed.

codex-runtime-docker-gate.sh uses the pinned agent-codex image and a local fake Responses provider to verify the version/help surface, unauthenticated probe, file-backed credentials, disabled startup updates, doctor checks, a real exec_command, explicit conversation resume, restart persistence, and graceful SIGTERM. Codex state persists in /home/agent/.config/codex; project output persists in /workspace. The production container's no-new-privileges/seccomp profile intentionally blocks nested bubblewrap, so Codex follows OpenAI's container guidance and uses danger-full-access inside the container. The per-user container remains the enforced boundary through zero long-lived capabilities, NNP, seccomp, and separate bind roots. Cleanup is scoped to this gate's immutable ID and random label.

Security and isolation boundaries

The security boundary is the member runtime, not a project directory or session socket

A PER_USER RuntimeProfile provisions a separate UserRuntime for every approved member: its own Agent container, workspace bind, configuration bind, and CLI sign-in state. Members do not share Claude/Codex credentials, project files, conversation directories, or session sockets. Revoking a member converges by stopping that member's runtime.

Several Staff instances owned by the same member still share that UserRuntime's CLI account state. Claude persists under /home/agent/.config/claude; Codex persists under /home/agent/.config/codex. This is the correct scope for authorization and container replacement, but it must never cross member boundaries. Each instance keeps its own project, memory entry point, and output under /workspace/<instance slug>.

A member who can open a terminal has shell access to that UserRuntime and can therefore read that member's other projects and CLI sign-in files. Project permissions and separate session sockets prevent accidents; they are not an adversarial boundary within one operating-system user. Never assign one UserRuntime to mutually untrusted members.

LEGACY_SHARED was removed with the 2026-09 environment cutover: since then only PER_USER exists, every member has one runtime per policy, and the member-level isolation above holds for every new instance.

The runtime policy for new staff is the workspace default runtime policy (the single entry at the top of Skill setup in the admin UI); which environment they land on follows the environments and priorities checked on that policy (/admin/runtime-profiles). Skills are not bound to a runtime.

Job roles bound to `LEGACY_SHARED` before the 2026-09-02 migration already start their new staff in the member's private runtime. Personas that relied on the shared container being signed in may not hold there; the migration script prints those roles and an administrator confirms them one by one (see 3b). Their preset env moved off the skills with the credentials migration of the same day.

Credentials do not live on skills. A skill only declares the names it needs; values live in three layers (since 2026-09-05): a key given to one staff member, a member's own credentials, and workspace credentials (set by an administrator, shared by every member). The order is staff → member → workspace: the narrowest one wins, and the same resolver decides it in all three places (the hire preflight, the start-time injection, and the skill catalog). Values are encrypted with the secret envelope (the ciphertext is bound to the name and to the member or staff member, so it cannot be moved — a row moved onto another staff member does not decrypt, which refuses the start rather than delivering the wrong value) and are write-only: every API response, audit row and log line carries names only, and administrators cannot read a value back either. Hiring resolves by name and names the missing ones for the member to fill in, each with its own "who gets this key" choice that defaults to the one staff member being hired; at start the Gateway decrypts in place and injects into the staff member's session, and refuses to start when one is missing or unreadable (no half-configured staff). Values never enter the Staff row, the persona file or the conversation snapshot. Member credentials are never shared between members; workspace credentials are available to everyone, and there is no per-skill authorization.

"This staff member only" is a delivery scope, not an isolation boundary. The value is handed to that one staff member's tmux session, but every staff member inside one runtime runs in the same container under the same OS user, the socket directory is listable and a sibling's /proc/<pid>/environ is readable (every staff member in one runtime belongs to the same member). The member-facing copy therefore says who is *given* a key and never who cannot read it. Replacing a staff-scoped value takes effect the next time that staff member starts work: restarting a session that is still alive changes nothing, so the session has to end first. Off-boarding (once closedAt is written) destroys that staff member's staff-scoped credentials in the same transaction, recording the count only, never a name. Revoking a member's approval is a fence, not a destruction: it blocks new hires and new starts and destroys nothing. Deleting a member does destroy their member- and staff-scoped credentials, while their staff rows survive with no owner (Staff.ownerUserId is SetNull) and fail their next start with CREDENTIALS_MISSING, naming the key — fail closed.

  • A ZIP or Git import first becomes a private, member-owned ProjectRevision.

Publishing Staff verifies it again and creates a workspace-owned immutable WorkspaceProjectAsset; instances copy that read-only baseline into writable projects without receiving the source path or the publisher's sign-in state.

  • Replacing a container briefly interrupts active conversations. Persistent

workspace/config binds and the provider's explicit conversation identity allow the Gateway to resume them. The UI reports the interruption until convergence.

  • CurvePulse is single-server, self-hosted software. It is not a SaaS or a

multi-tenant service. One installation represents one organization; approved members share the control plane, the skill catalog, infrastructure and routes, while member runtimes enforce the cross-member compute and credential boundary.

  • Permission levels of a multi-skill staff member are never merged: the

selected skills must share one level, and a mixed selection is refused at hiring time (for administrators too). Two skills that need a credential of the same name are not a conflict: the same name is the same credential, and the union only has to be complete before hiring; only a union above the limit is refused.