OpenHost Sculptor: deploy / verify / reset
Operate the self-hosted Sculptor app on an OpenHost compute space. Sculptor is
deployed there as an OpenHost app built from source (not a released binary):
OpenHost clones the repo at a branch, builds openhost.Dockerfile, runs the
container under rootless podman, and reverse-proxies HTTPS to it behind
OpenHost's owner login.
Everything here drives the oh CLI (OpenHost's app-management CLI) — no SSH
into the host for any normal flow. The four common flows are wrapped in scripts
under scripts/; the raw oh commands they run are shown alongside so you know
what each does.
Config
This skill is Sculptor-specific, so the values are baked into the scripts — no config file to set up:
APP—sculptor, hardcoded in each script.REPO—https://github.com/imbue-ai/sculptor, hardcoded in each script.BRANCH— the repo's current git branch (git rev-parse --abbrev-ref HEAD).HOST— the public URL host thatverify.shcurls (e.g.sculptor.<your-zone>). This is the only per-machine value — it embeds a personal subdomain, so it isn't committed. It lives in.sculptor/.env(gitignored via**/.env) asOPENHOST_HOST.verify.shtakes it as an argument; getting it from.sculptor/.envis the caller's job (see Verify below) — the script does no file I/O or prompting.
When you need HOST and .sculptor/.env doesn't have it yet, ask the user for
their instance host and save it so future runs don't have to ask:
echo "OPENHOST_HOST=sculptor.<your-zone>" >>.sculptor/.env # gitignored
To run ad-hoc oh commands in a shell, just use those values directly, e.g.:
oh app status sculptor # see "Gotchas": older instances need the app_id, not the name
oh app deploy "https://github.com/imbue-ai/sculptor@$(git rev-parse --abbrev-ref HEAD)" \
--name sculptor --wait
Prereqs
ohCLI installed and authenticated on this machine.- Install (it's not on PyPI — installs from the private openhost repo):
uv tool install "oh @ git+https://github.com/imbue-ai/openhost.git#subdirectory=compute_space_cli"uvdrops the binary at~/.local/bin/oh; add that to PATH if needed. - Authenticate once with
oh login(prompts for the compute-space URL and a token). Sanity check:oh statusshould print<url> — up (HTTP 200). (Don't rely onoh app listfor the check — it crashes against older instances; see "Gotchas".)
- Install (it's not on PyPI — installs from the private openhost repo):
- The branch must be pushed to GitHub first. The deploy builds from
$REPO@$BRANCH— OpenHost clones from GitHub, so unpushed local commits are invisible to it. Push (with the user's permission) before deploying. - Repo root carries the deploy artifacts:
openhost.toml(the app manifest — name, port5050, health check, persistentapp_data) andopenhost.Dockerfile(the from-source build recipe). The build context is always the repo root.
How it runs
Knowing how the container is wired explains the deploy and reset behavior below.
- The Dockerfile
CMDrunspython -m sculptor.cli.main --no-open-browser /workspacefrom the built uv venv at/app/.venv— the backend serves the bundled web UI itself. No Electron, nojust start. It's effectivelyjust backendwith static serving on and/workspace(a minimal pre-initialized git repo) as the initial project. - The backend listens on
0.0.0.0:5050inside the container; OpenHost proxies HTTPS athttps://$HOST/behind its SSO owner login. - Persistent state lives under
/data/app_data/sculptor(envSCULPTOR_FOLDER) — DB, workspaces, downloaded agent binaries, and config. This dir is an OpenHost backed-upapp_datamount, so it survives rebuilds andoh app remove --keep-data. Claude Code OAuth creds persist there too (CLAUDE_CONFIG_DIR=/data/app_data/sculptor/claude).
Deploy / update
Each script deploys the current git branch (BRANCH) and runs oh with --wait
(the from-source build takes ~10 min — uv sync, frontend pnpm install /
generate-api / build; don't assume it's instant).
Fresh deploy — app name not yet in use
.claude/skills/openhost-sculptor/scripts/deploy.sh
# → oh app deploy "$REPO@$BRANCH" --name "$APP" --wait
Update, or switch branches — keep data
oh app deploy refuses an existing app name ("App name already in use"), so
redeploys remove first. redeploy.sh removes with --keep-data (persistent
app_data survives), then deploys the same or a different $BRANCH. oh app remove blocks until the app is gone, so the deploy reuses the name cleanly.
.claude/skills/openhost-sculptor/scripts/redeploy.sh
# → oh app remove "$APP" --keep-data
# → oh app deploy "$REPO@$BRANCH" --name "$APP" --wait
Note: oh app reload "$APP" --update only git pulls and rebuilds the same
branch already deployed — it does not switch branches. Switching branches
always means remove + deploy (i.e. redeploy.sh).
Verify
After a deploy, confirm all three with one script. It takes your instance host as
an argument — load it from .sculptor/.env (see Config; add it there first if
missing):
. .sculptor/.env # sets OPENHOST_HOST
.claude/skills/openhost-sculptor/scripts/verify.sh "$OPENHOST_HOST"
It checks:
- It's up —
oh app status "$APP"prints<app>: <status>(expectrunning;buildingmeans the deploy is still in progress). To confirm which code is live (branch/SHA), check the deploy's build logs —oh app statusreports only the status string. (On an instance older than the CLI this call needs the app_id, not the name — see "Gotchas".) - It's serving —
curlthe live URL.302= healthy. That's the OpenHost SSO login redirect, not an error.502/503= down (still building, crashed, or failed to bind).
- Clean boot —
oh app logs "$APP"showsUvicorn running on http://0.0.0.0:5050andApplication startup complete.
(OpenHost's own readiness probe hits the unauthenticated /api/v1/health, per
openhost.toml.)
Reset — fresh-onboarding instance (CLI only, no SSH)
To wipe everything (DB, workspaces, Claude auth, completed-onboarding state) and
come back up at first-run onboarding, reset.sh removes the app without
--keep-data — which deletes the persistent app_data — then redeploys fresh.
It prompts for confirmation first, then rebuilds (~10 min).
.claude/skills/openhost-sculptor/scripts/reset.sh
# → oh app remove "$APP" # no --keep-data: deletes persistent app_data
# → oh app deploy "$REPO@$BRANCH" --name "$APP" --wait
oh app remove --keep-data (what redeploy.sh uses) preserves app_data,
including a completed-onboarding config — so a redeploy drops you straight
into the app. Use reset.sh when you specifically want fresh onboarding.
Escape hatch
A few things the oh CLI can't do — exec a command inside the running container,
wipe only part of the data, or restart without a rebuild — require host access:
oh instance ssh opens a shell on the instance, from which you can drive
podman directly against the openhost-$APP container. You shouldn't need this
for any flow above; reach for it only for one-off inspection or surgical fixes.
Gotchas
An instance older than the
ohCLI breaks name-basedappcommands. The current CLI (and openhost repo HEAD) addresses apps by name and expectsGET /api/appsto return a name-keyed dict. Older compute-space servers instead return a list of{app_id, name, status}and keyapp_status/app_logson an opaque app_id. Against such an instance:oh app listcrashes withAttributeError: 'list' object has no attribute 'items'(it calls.items()on the list).oh app status <name>/oh app logs <name>fail withError (400): Invalid app_id— they need the app_id, which changes on every remove+redeploy.
Fetch the current app_id (and a working list) by calling the API directly with the CLI's own saved auth, then pass the id to
status/logs:~/.local/share/uv/tools/oh/bin/python - <<'PY' import json from compute_space_cli import config from compute_space_cli.main import make_api_request mc = config.MultiConfig.load() inst = mc.instances[mc.default_instance or "default"] print(json.dumps(make_api_request(inst.url, inst.token, "GET", "/api/apps").json(), indent=2)) PY # → oh app logs <app_id>The durable fix is to update the compute-space instance to current openhost; then the name-based commands (and these scripts) work as written.
A returning browser can skip onboarding/Add Workspace. Post-onboarding landing is driven by the browser's
sculptor-tabslocalStorage, not the server: the root route reopens the last active tab, and only with no saved tabs falls back to/ws/new. After a reset, a browser with stale tabs can land on home or a now-deleted workspace. Fix in the browser:localStorage.clear()in devtools, delete thesculptor-tabskey, or use an incognito window.