Skip to content
sitesolide

Docs Agents

Agents

Small software is more and more often written by an agent: Claude Code, Codex, Cursor. sitesolide gives an agent three ways in, and none of them gives it more than a person at a terminal has:

  • the CLI with --json, for an agent that runs commands;
  • an MCP server, sitesolide mcp, for an agent that calls tools;
  • a skill, skills/sitesolide/SKILL.md, which teaches the workflow.

All three run the same commands, with the same refusals. A deploy an agent starts reads the general access the dashboard set, takes the Caddy lock, checks the secrets on the machine and rolls a broken Caddy block back exactly as yours would.

What an agent may do, and what stays yours

Link to the section What an agent may do, and what stays yours
An agent canIt cannot
infer a manifest for a folder (detect)remove a project, which the machine keeps no backup of
dry-run a deploy, and deploylock or unlock a preview, switch a domain
read the server's status, a project's logs, its lock state--force over a file someone edited on the machine
give the people you named access to a project, by email or @domain, with a role, and read who has accessgive access to anyone you did not name, or make a site Public, which is its general access, the dashboard's
list the machines sitesolide created at a cloud providerdestroy one, unless you asked for that machine to be destroyed
check a machine with setup --dry-run, and install it when you askedreplace DNS records that point elsewhere (setup --dns-replace), unless you decided it
list what a new release would change with upgrade --dry-run and refresh --all --dry-run, and run them when you askedredeploy a component by hand, or force over a file edited on the machine
list everyone with access, and who may create projects, with peoplegrant or take back the right to create projects, unless you asked for that person: it lets them into the dashboard
with a token, restart a project's serviceread or guess a secret, a provider's token included: no route hands a value back
with a token minted with Manage secrets, list a project's secret files and variable names, set a variable to a value it generated itself, a random key, and remove the ones you namedset a value read from the conversation, or put one on a command line

Secrets are set in the dashboard's Secrets section, by you, or, with a token minted with Manage secrets, by you running sitesolide secrets set NAME, which asks for the value without echoing it. An agent pipes to sitesolide secrets set NAME --stdin only a value it generates itself, openssl rand -hex 32 | sitesolide secrets set SESSION_SECRET --stdin --json, never one read from the conversation, which would keep it; NAME=value or a value as an argument is refused before anything is sent, so is a name that looks like a value, and under --json set never prompts. There is no MCP tool for it, on purpose. A deploy that needs one the machine lacks stops and names the page where to create it; the agent tells you, and runs the deploy again once you have. Nothing an agent writes into the repository or the manifest ever carries a secret value: the manifest validation refuses an env key that looks like one.

Building a small tool ends with "give it to the people who need it". Two tools, and one command behind both:

  • access (sitesolide share --json) reads a project's general access (Public, Restricted to the people with access, or Anyone with the code), its people with access, each with their role, and the line to send them;
  • share (sitesolide share <who>... --json) gives people access, by email or a whole domain written @acme.com, with a role: can-open (Can open, the default), viewer, developer or admin, each including the ones below it; or takes it away, with remove. One direction per call.

Giving access lets real people into the app and the data it holds, from their next request, and from Viewer up into the project in the dashboard: the tool's description says so first. An agent asks the user before every call, naming the exact addresses, domains and role, gives access to those and nobody else, and reads access before and after to show the user who has it. Leave share on "ask" in your client.

Password access is shown once. Someone outside the company's domains, or anyone when signing in with a company account is not set up, is given Can open with a password the machine draws for them. It comes back once, in an info event and in the result's changes, for the user to send them: tell the user, and never store, log or repeat it anywhere else. expires sets how long it lasts, 24h, 7d (the default), 30d or never.

$ sitesolide share alice@acme.com --json
{"type":"step","message":"access to notes, https://notes.example.com/, over SSH, as the owner"}
{"type":"step","message":"alice@acme.com: Can open on notes"}
{"type":"info","message":"general access: Restricted: visitors are asked to sign in."}
...
{"type":"info","message":"send: Open https://notes.example.com/ and sign in with your Google account."}
{"type":"result","ok":true,"command":"share","slug":"notes","url":"https://notes.example.com/","general":"restricted","entries":[{"who":"@acme.com","kind":"domain","role":"can-open","by":"owner","createdAt":1791450000000,"updatedAt":1791450000000,"password":null},{"who":"alice@acme.com","kind":"person","role":"can-open","by":"owner","createdAt":1791450000000,"updatedAt":1791450000000,"password":null}],"signIn":{"configured":true,"allowedDomains":["acme.com"]},"message":"Open https://notes.example.com/ and sign in with your Google account.","changed":true,"changes":[{"who":"alice@acme.com","change":"add","role":"can-open"}]}

entries lists every entry as it stands after the change: who, kind (person, domain, or password for password access), role, by, createdAt, updatedAt, and password, { expiresAt, expired } for password access, null otherwise. changes says what this call did, each { who, change, role }, change being add, role, none or remove (role null then), with password for password access just given. message is the line to send people who sign in with a company account, null until that is set up; the send: event comes only after a change that gives someone access with an account. A warning that the portal still decides from its own tables means the machine is halfway through an upgrade: tell the user, whose owner runs sitesolide upgrade; what was given is kept.

Never done this way: changing a site's general access, Public, Restricted or Anyone with the code, which is the dashboard's, or sitesolide lock for the owner to run; never on an agent's own initiative. With a token, share gives Can open alone, to people who sign in with a company account or to one of the company's domains, never password access, and takes Can open entries off: see access.md.

The refusals, each carrying its hint:

CodeWhat it meansWhat to do
usage, unknown-optionthe call itself is wrongrun what details lists
invalidneither an email nor a @domain, a domain given more than Can open, someone outside the company's domains given more than Can openfix what the message names
out-of-scopewhoever asks may not: a token giving more than Can open, password access or a domain outside the company's, or taking off an entry above Can open; a person's own token on a project they are not Admin of; a project of the platformchange what the message names, or ask the owner of the machine; never another slug, role or token
not-foundno such project for this token, a project not deployed, or someone with no access to take offcheck the slug in sitesolide.json; sitesolide status lists the projects
lockedthe change needs the dashboard's unlockthe user makes it from the dashboard, or the owner over SSH
ssh-faileda command over SSH failed, nothing changedrun it again; if it fails twice, check that ssh <server> true connects without a prompt
steward-outdatedover the owner's SSH, the steward does not keep people with access yetthe user runs sitesolide upgrade when they decide to, then the same command again
not-availablewith a token, the dashboard or its steward does not carry access yet; or the access log is half full, the half a token may fill, and the token lets nobody more intell the user: the owner runs sitesolide upgrade, or someone gives the access from the dashboard
too-many-attemptswith a token, its hour of refused requests used up, 60, its refused changes of access among themwait the seconds details names, and tell the user what was refused; never retry in a loop
unauthenticatedthe token is missing, expired or revoked; or the vault's token and an address other than the one login checked it againstask the owner for one; never guess or borrow a token, nor point SITESOLIDE_API elsewhere
failuresomething broke on the machinetell the owner of the machine, with the message; never retry in a loop

With a token minted with Manage secrets, sitesolide secrets --json lists the project's secret files and their variable names, never a value, and whether a restart is pending; a token without it is refused. Such a token changes <slug>.env alone, once the deployed sitesolide.json declares it: secrets set NAME --stdin and secrets remove NAME..., --restart to apply the change. sitesolide restart --json restarts the service, for any token that deploys the project, and ends in an error unless the service holds. Every refusal the steward decides is journaled under the token, 60 an hour, its refused changes of access among them. See access.md.

The refusals of their own, each carrying its hint:

CodeWhat it meansWhat to do
value-in-argumentsa value, or NAME=value, on the command line: refused before anything is sent, never quotedthe user runs sitesolide secrets set NAME and types it; pipe to --stdin only a value you generated; if a real value was on the line, tell the user to change it
needs-stdinset under --json, or without a terminal, and no --stdinthe same: nothing prompts there
stdin-is-terminal--stdin while standard input is a terminal, where the value would show as it is typed: nothing read, nothing sentpipe a value you generated; or the user drops --stdin and types the value at the prompt
looks-like-valuea name over 64 characters, or starting like a service's key (sk_, rk_, pk_live, whsec_, ghp_, gho_, github_pat_, xox, AKIA, sst_, glpat-): refused before anything is sent, never quotedtell the user it may be in the shell's history and to change it at its source; then the variable's real name
out-of-scopea token without Manage secrets, listing included, a file other than <slug>.env, one the deployed manifest does not declare, PASSWORD_HASH, a person's role below Developer; an owner's token without public sites on a project not Restricted or one it could not deploy as it standsask the user; never another token or file
invalida name the machine keeps for itself or the unit sets, an empty value; a value on several lines, with a null byte, not valid Unicode, with a Unicode noncharacter or over 8 KiB, refused before it is sent; at the prompt, a control key or a pasted block of several linesfix what the message names
unmanagedthe file was written by hand in a form the dashboard does not rewrite: nothing changedtell the owner of the machine
busya deployment of the project runs, the token's previous restart is still under way, or the machine is busy with other changeswait, then run the same command once
too-many-attemptsthe token's hour used up: 120 changes, 30 restarts, 60 refused requests, refused changes of access includedwait the seconds details names; never retry in a loop
unconfirmedthe change was sent and no answer came: it may be writtenrun sitesolide secrets --json, then the same command once; never in a loop
restart-failedthe service did not hold after its restart; a change made with it is writtenread logs, fix the cause, then sitesolide restart once
not-availablethe dashboard or its steward does not carry secrets by token yettell the owner of the machine, who runs sitesolide upgrade

A folder without a sitesolide.json is no longer a dead end:

$ cd shop && sitesolide detect
-> this folder reads as a Bun app
   from: package.json; server.ts starts a server
{
  "slug": "shop",
  "publicDir": "public",
  "install": "/usr/local/bin/bun install --production",
  "start": "/usr/local/bin/bun run server.ts",
  "env": {
    "NODE_ENV": "production"
  },
  "exclude": [
    "node_modules"
  ]
}
   note: the dependencies are installed on the server, by bun install --production
   note: the code seems to reach the network (server.ts:2): a service only reaches the loopback by default, DNS included; add "network": "outbound" if it does
   note: the code reads STRIPE_SECRET_KEY: secrets live on the server, never in the repository; declare "secrets": ["shop.env"] and set their values in the dashboard's Secrets section
   note: no "port": deploy picks a free one on the server and writes it into sitesolide.json
   nothing was written; to write it: sitesolide detect --write

detect --write writes it, never over an existing one. deploy in such a folder shows the same manifest and stops, giving the command that accepts it; deploy --yes writes it and deploys. --slug <name> names the project otherwise than after its folder.

detect reads the folder and nothing outside it: a symbolic link leading out, a package.json linked to a file of your home directory for instance, is never followed, and the folder is refused, naming the link. A file that does not parse is said not to, without the parser's message, which quotes the file.

What is recognised, first match wins:

The folder holdsThe manifest
go.moda binary cross-compiled for linux/amd64 on the workstation, started from app/
pyproject.toml or requirements.txt naming FastAPI or Flaska virtualenv built on the machine by uv, uvicorn or gunicorn on ${PORT}
a package.json with a server: a start script, or a file that listensthe machine's Bun runs it, public/ served by Caddy
a package.json whose generator builds files: Vite, Astro, Eleventy, Gatsby...the build on the workstation, its output served
a server file alone, server.ts with Bun.servethe machine's Bun runs it as it is
index.html under public/, dist/, build/, _site/ or out/that folder, served as it is

What is never decided for you. A secret is never guessed: code that reads STRIPE_SECRET_KEY gets a note, not a value nor a declared file. The network is never opened: code that obviously calls out gets a note naming the line, and "network": "outbound" stays your decision. Neither .git nor a .env ever leaves, at any depth, from the code or the public folder, and a note names the ones the folder holds. An index.html at the root of a folder is refused rather than served from there, because the root would serve .git with it: move the site into public/.

A name the machine already serves is refused. An inferred manifest names the project after its folder, and a folder called api is not the only one: deploy --yes stops if the machine already carries a project of that name, before anything is written. Pick another with --slug.

The port is chosen by deploy. An app whose manifest has no port gets the lowest one the machine's manifests leave free between 3000 and 3099, the range the loopback rule closes, never one of the platform's own services. A project the machine already carries keeps the port it has there. The port is written into the local sitesolide.json, with a message: commit it, and every later deploy, from any workstation, keeps it. A dry run says which port it would take and writes nothing. A project of several services declares its servers' ports itself, since its services name each other's in their env.

A dry run runs nothing of the folder. The manifest's build is the folder's own code, and a dry run is what an agent tries first, on a folder it may have just cloned, from the workstation that holds root SSH to the machine: it shows the build as a planned event, and does not run it. Only a person typing sitesolide deploy --dry-run --build runs it in a dry run; the MCP tool never does. Likewise, a name inference would put into a command, a folder under cmd/ or a server file, is declined unless it is made of letters, digits, dots, dashes and underscores, and a manifest outside your sites repository may only point source inside its own repository.

detect infers web servers and static sites. A process that is not one, "a bot that stays connected", "something that runs every hour", is written by hand under services, never as the top-level start, which is always a server:

"services": {
  "web":    { "start": "/usr/local/bin/bun run web.ts", "port": 3041 },
  "worker": { "start": "/usr/local/bin/bun run worker.ts", "internal": true },
  "report": { "start": "/usr/local/bin/bun run report.ts", "schedule": "Mon..Fri 08:00", "timeout": "15m" }
}
  • A worker, "internal": true and no port, runs for good and listens on nothing. systemd restarts it when it falls, waiting longer each time, up to 5 minutes. Write it to exit cleanly on SIGTERM.
  • A scheduled service has a schedule in systemd's calendar syntax, hourly, daily, *:0/15, Mon..Fri 08:00, *-*-* 06:30 Europe/Paris, and runs to its end at those times; timeout, 1h by default and a day at most, stops a run that hangs. No port, routes nor backup. A deployment does not run it: the first run is the next time the schedule names.
  • cpu, 50% for half a core, caps a service, or every service at the top level. Workers and scheduled services already yield the processor to the sites.
  • A project of workers and scheduled services alone, without publicDir, serves no page: its address answers a line saying so, and domain is refused.

deploy asks systemd on the machine whether it reads each schedule, before anything is written and in a dry run too, and stops on one it does not: fix the expression, never drop the schedule to get past it. After the restart it watches every server and worker for ten seconds, and fails, naming each, when one fell: read logs. status shows a scheduled service's last and next run, and its runs are in logs.

Some work does not belong on the machine that serves every site: a headless browser, whose sandbox the unit's confinement refuses; heavy computing, which slows every site while it runs; mass email from the machine's own address, whose reputation every site shares. Say so to the user before writing one, see manifest.md.

Every command but init and run takes --json: standard output then carries one JSON object per line, nothing else, and the run ends with exactly one result or one error, always the last line: the Caddy lock is released before it, so that what its release says comes first. A failure exits non-zero. Through a token, the same holds.

$ sitesolide deploy --dry-run --json
{"type":"step","message":"project shop, service"}
{"type":"step","message":"port 3042, the lowest free one on the server"}
{"type":"planned","message":"write /home/me/shop/sitesolide.json with \"port\": 3042"}
{"type":"file","name":"shop.service","content":"# Service of project shop, ..."}
{"type":"planned","message":"rsync -a --delete --exclude node_modules ..."}
{"type":"result","ok":true,"command":"deploy","slug":"shop","kind":"service","dryRun":true,"manifestWritten":false,"port":3042,"portChosen":"free","portal":false,"url":"https://shop.example.com/","status":null}
typeFieldsWhat it is
stepmessagea step of the run, the -> lines of the human output
infomessagesomething said along the way
plannedmessagewhat a dry run would have done
warningmessage, detailssomething wrong that stops nothing
outputstream, linea line printed by a command the CLI launched: the build, rsync, a script
filename, contenta generated systemd unit or Caddy block
inferredkind, manifest, reasons, notesthe manifest inferred for a folder without one
logat, unit, priority, messageone journal entry, for logs
checkstep, status (done, ok, skip, todo, fail), title, detailone line of setup's or upgrade's checklist
resultok, command, and the command's datathe end of a run that succeeded
errormessage, details, hintthe end of a run that failed

What result carries:

CommandFields
deployslug, kind (static, service, services), runs for an app (servers, workers, scheduled, how many of each), dryRun, port (null when the main service listens on nothing), portChosen (kept, free) when chosen, portal, inferred when the manifest was, manifestWritten, url, status (HTTP, null in a dry run), headless (true for a project that serves no page); with --compare, compared, changes (what would change on the server, empty when nothing would) and kept (units edited on the machine, which deploy leaves)
detectkind, manifest, reasons, notes, written
statusprojects (each with its services), ports, memory, in megabytes; a scheduled service between two runs reads waiting, and carries schedule, lastRun, lastResult and nextRun
logsslug, units, entries
lock --statusslug, lock: wanted, installed, withoutCode, withCode, domain
lock, unlockslug, access (code or public), code and url (null once Public), manifestWritten; in a dry run dryRun alone
domainslug, domain: name, aliases, active, table, dns, https
removeslug, dryRun
secrets, with a tokenlisting: slug, secrets (slug, service, and files, each name, kind, state, reason, variables, passwords, restartPending, writable, never a value); set and remove: action, slug, file, created, set, removed, absent, restart (null, or { verdict })
restart, with a tokenslug, verdict (kind, state, subState, restarts): a kind other than active ends in an error instead
shareslug, url, general (public, restricted, code, or null for a project not deployed), entries (each who, kind, role, by, createdAt, updatedAt, password), signIn (configured, allowedDomains), message (null until signing in with a company account is set up), changed, and after a change changes (each who, change: add, role, none or remove, role, and password for password access just given)
peoplelisting: people (each who, roles by project, create, passwords, admin), domains (each slug, domain), signIn, changed: false; --may-create and --no-create: email, create, roles, change (create, none), changed
machine createprovider, created (false when an earlier run had), machine, resources (each kind, name, id, reused), next, the setup command to run
machine listprovider, machines, each with name, type, location, status, ipv4, ipv6, ipv6Network, monthlyPrice (net, gross, currency), backups, managed
machine destroyprovider, name, removed, kept, dns
setupdryRun, server, zone, steps (each step, status, detail); after a real run also dashboard, portal, configuration, ran, already, skipped, passwords, next
upgradedryRun, server, zone, release, components (each component, title, state: up-to-date, out-of-date, missing, upgraded, failed, detail, run), upgraded, upToDate, outOfDate, missing, next (the setup command that installs what is missing)
refreshdryRun, all, projects (each slug, state: refreshed, outdated in a dry run, current, kept, refused, static; write, current, kept, the unit files by what was done with them; reasons, why it was refused; url, status), and the slugs by state: refreshed, outdated, current, kept, refused, static

upgrade changes the machine in service. --dry-run only reads it, and says what would run; the run itself redeploys live components, the dashboard, the portal, Caddy's configuration, and is the user's decision, never an agent's own. It never reads a secret nor draws a password. A component it reports missing is setup's to install, on the same decision.

refresh restarts the apps in service. --dry-run only reads the manifests and the units on the machine; the run writes each project's units again, without its code, and restarts its services, one project at a time, stopping at the first that does not hold. It is the user's decision, after an upgrade whose notes say the generated units changed. A unit edited by hand is kept, never replaced: switching it is deploy --force, the owner's.

setup never puts a password in an event. The dashboard's and the portal's, when a run draws them, go to standard error once, for the person at the terminal; passwords only says that they did. See setup.md.

manifestWritten: true means sitesolide.json changed on disk: an inferred manifest, a port chosen, or general access the dashboard set. Commit it.

Every error carries a hint: the one action to take next, and the ones not to, re-running with --force or changing the server by hand above all. The hints live in bin/cli/hints.ts, and a test fails on a refusal without one.

logs --json reads journalctl's own JSON, one log event per entry; --follow keeps the stream open, --lines <n> goes further back.

ssh never prompts under --json. A passphrase or a host key to confirm fails the connection at once rather than waiting for a keyboard an agent does not have. Load the key into the agent first, ssh-add, and connect once by hand so that the host key is known.

sitesolide mcp speaks the Model Context Protocol on standard input and output. Each tool runs the CLI with --json in the folder it names and returns its events: the result or the error, and what led to it. It needs no dependency and no configuration of its own: the CLI's configuration is read by each command.

ToolArgumentsChanges anything
detectfolder, slugno, and reads no machine
deployfolder, dry_run, accept_inferred, slugyes, the live site, unless dry_run, which runs not even the build
statusno
logsfolder, linesno
accessfolderno
sharefolder, who, role, expires, removeyes, who may open the app and its data, and from Viewer up who sees or changes the project in the dashboard
lock_statusfolderno

setup, upgrade and refresh are not tools: they change the machine itself, or every app on it, and run through the CLI, when the user asked. Neither is people: the right to create projects lets someone into the dashboard, which is the owner's to give, over their SSH. Nor are secrets and restart: a tool that set a secret would take its value from the conversation, and the conversation would keep it. folder is an absolute path. deploy's description says first that it changes the live server, and share's that it gives real people access: leave both on "ask" in your client rather than allowing them once and for all, so that every real deploy, and every person let in, is yours to approve.

It speaks both eras of the protocol: 2026-07-28, with no handshake and the version on every request, and 2025-11-25 back to 2024-11-05, with initialize. notifications/cancelled interrupts a command as Ctrl-C would: the step in progress finishes, then it stops and releases the Caddy lock. A request whose id is still in flight is refused, so that an answer or a cancellation always names one command, and a message longer than a mebibyte is refused unread.

install.sh puts the sitesolide binary in ~/.local/bin, see install.md. With that on your PATH:

Claude Code, for every project:

claude mcp add --scope user --transport stdio sitesolide -- sitesolide mcp

or in one repository, shared through its .mcp.json:

{
  "mcpServers": {
    "sitesolide": { "type": "stdio", "command": "sitesolide", "args": ["mcp"] }
  }
}

Codex, in ~/.codex/config.toml:

[mcp_servers.sitesolide]
command = "sitesolide"
args = ["mcp"]

Cursor, in ~/.cursor/mcp.json or a project's .cursor/mcp.json:

{ "mcpServers": { "sitesolide": { "command": "sitesolide", "args": ["mcp"] } } }

Any other client that launches stdio servers: the command sitesolide, the argument mcp. Without it on the PATH, give the binary's full path, or from a checkout bun /path/to/sitesolide/bin/sitesolide.ts mcp.

skills/sitesolide/SKILL.md teaches an agent the workflow: detect, review the manifest with you, dry-run, deploy, read the result, give the people who need it access, read the logs when something fails, and where secrets go. Claude Code finds it in ~/.claude/skills/. The binary does not carry it: from a clone of this repository, link it,

mkdir -p ~/.claude/skills
ln -s "$PWD/skills/sitesolide" ~/.claude/skills/sitesolide

or copy skills/sitesolide/SKILL.md into ~/.claude/skills/sitesolide/, or, for one repository only, into its .claude/skills/sitesolide/. It works with the MCP server or without it, through the CLI.

llms.txt, at the root of the repository, is what an agent reads before it acts, in the llmstxt.org format: how to tell whether sitesolide is set up, the deploy loop, the --json contract, the MCP tools, tokens, access, a new server, the rules an agent never breaks, and links to these documents.

Agents arrived in 0.2: a workstation on 0.2 or later has all of this, and upgrading.md is the order from one release to the next.

Everything here runs on the workstation. Nothing changes on the machine, and nothing has to be run there: the commands send the same remote commands they always did, the journal's JSON aside, which logs --json alone asks for.

  1. Pull the repository on the workstation. Check: sitesolide detect in a project folder prints a manifest, and sitesolide deploy --dry-run --json ends with a result line.
  2. Optionally, register the MCP server and link the skill, as above. Check: claude mcp list shows sitesolide connected.

To roll back: check out the previous commit, claude mcp remove sitesolide, and remove the ~/.claude/skills/sitesolide link.

One behaviour changed: an app whose manifest has no port used to be refused, and is now given one by deploy. Every manifest already deployed declares its port and is untouched.

share and the access tool need the machine's side: a steward that keeps the access registry, and with a token the dashboard of the same release, which sitesolide upgrade brings both: see upgrading.md.

A later release hardened what an agent, a token or a cloned folder can make deploy do: a dry run no longer runs the build, install runs as the project's account in its service's walls, .git and .env never leave, and more. Some of it does change the machine, and has an order: see commands.md, "Upgrading to the hardened deploy".

What only the machine can confirm, on the first real deploy of each kind:

  • an inferred Python app's start uses ${PORT}, which systemd expands from the unit's own Environment=PORT=: systemctl show <slug> -p ExecStart shows the line, ss -ltn | grep <port> shows uvicorn or gunicorn listening on it;
  • sitesolide logs --json relies on journalctl -o json --output-fields, systemd 236 or later: it prints log events with a unit and a priority;
  • an inferred Python app's install uses /usr/local/bin/uv, which the machine must carry, like every Python project here.