--- title: Holm description: Technical reference for Holm 0.272.0 and implemented Holm App Spec v1 apps version: 0.272.0 updated: 2026-10-29 source: holm-release-v0.272.0 command help, public release notes and the shipped Holm App Spec v1 guide canonical_url: https://holmhq.com/llms-full.txt format: llms-full.txt status: beta --- # Holm This is the canonical, self-contained technical reference for Holm, for human developers and software-engineering agents. It teaches only Holm App Spec v1 as implemented by the current binary; operator release provenance is 0.272.0. The typed HTTP jobs subsection requires a build containing that subset, not an older released binary with the same version label. Every directory deploy requires `--spec-v1`. App authoring follows the [v1 guide](https://v1-spec.zyt.app/llms.txt), not future features described in the specification. Install, CLI and operator command spellings and listed flags were checked against the 0.252.0 binary's help, with the public release notes for the shipped changes. This was a help audit on 2026-10-21, not a live-node runtime test. Where a leaf does not implement help, its command-family help supplies the reference. Some leaf commands do not treat `--help` as a read-only operation; start with family help rather than probing a production mutation for help. ```bash holm --version holm --help holm app --help holm server --help holm peer --help ``` Holm is pre-1.0 and beta. Check the selected node's version before acting. ## Reading paths - New users: sections 1, 2, 4, 9 and 10. - App builders: sections 9–13, 15 and 22. - Node operators: sections 5–8, 14 and 16–23. - Coding agents: sections 2, 4 and 26. ## 1. Holm in one minute Holm is a self-hosted application platform for individuals and small teams. One Go binary hosts many apps; one SQLite database holds the node's state. A v1 app is static files plus, optionally, a JavaScript module answering requests with the standard Fetch API. There is no build step on the node. The app-facing surfaces covered here are static hosting, server-side JavaScript, sign-in, shared and member-private storage, realtime, and policy-controlled HTTP, email and externally configured AI calls. ```text browser, CLI, or API client | v one Holm process | v one complete SQLite cartridge ``` The binary is replaceable. The database is the instance. Verify Object completeness before treating one database file as a complete portable node. A peer is a named node addressed with `holm @name ...`, not an automatic cluster. Holm does not require a separate Node.js runtime for server code. ## 2. Non-negotiable operating rules 1. Treat the active SQLite database as the instance. 2. Never patch a live database with raw SQLite tools. 3. Use the CLI or an app's supported API for management and mutations. 4. Before copying one database file, verify `canonical_complete: true`. 5. Stop the node before Object placement or materialization operations. 6. Use exact host routes; an app name does not configure DNS or routing. 7. Deploy a reviewed directory with `--spec-v1` every time. 8. Redeploy replaces the whole app file set. Keep state in app storage. 9. Keep credentials in Holm secret stores, never in deployed files or logs. 10. Only `web/` is public in app mode; server-read files belong in `api/`. 11. Use `env.holm.net.fetch` for policy-controlled outbound HTTP. 12. Use an explicit `@peer` for remote work. Bare app commands may use a configured default peer, not the machine you intended. 13. Use a command's documented JSON option for automation. 14. Validation is not execution. Verify routes and sign-in in the actual app. 15. Expect breaking changes before 1.0; verify the target version. ## 3. Core concepts ### 3.1 Node and cartridge A node is a running Holm process and its durable state. The default local database is `~/.holm/data.db`. Select a different instance with `--db` on commands that support it or with `HOLM_DB_PATH`. A cartridge is a complete SQLite database. Hosted files and app blobs use content-addressed Objects. Bytes may be detached into a sibling `objects/` directory or a configured provider. A raw database copy is then incomplete until those bytes are materialized into a fresh standalone database. Confirm the actual database path; do not infer a managed service's path from the operator's home directory. ### 3.2 App and app identity An app is a hosted file tree and an isolation scope. `env.holm` storage is scoped to the current app. App identity is separate from routing: an exact FQDN host route maps a hostname to the durable app ID. Use the app ID printed by deploy for inspection and reports. Several hosts may point to one app and will see the same deployed files. ### 3.3 Member and authority A member represents a person or service principal. Node roles are `user`, `admin` and `owner`. A member PAT follows current member authority. A scoped API key authorizes only its granted operations. Remote binary upgrades require an owner or admin member credential. Even an API key with `system:*` does not grant binary-upgrade authority. A browser session belongs to the app's exact domain. It is not a deployment credential and must not be sent to another host. ### 3.4 Shared and member data `env.holm.app.kv`, `.ds` and `.s3` hold shared app data. Their `env.holm.app.member` variants are private to the signed-in visitor. Check sign-in before using member storage. Storage is not public by itself. To serve stored records or files, return them from the app's fetch handler and check caller access. ### 3.5 BFBB BFBB means "build free, but buildable." Raw browser files need no Node.js, npm or build step. If an app uses a bundler, build locally and deploy the result in the v1 folder shape. The node does not build it. ### 3.6 Settings, policy, providers and secrets - **Settings** are durable typed node and app configuration. - **Policy** supplies node/app gates and limits. - **Providers** select configured external services, such as email or AI. - **Secrets** hold runtime credentials or outbound-auth injection values. Node configuration lives in SQLite, not a primary YAML or JSON file. `holm.json` is app-owned serving configuration, not node configuration. ## 4. Ten-minute local start Use a disposable instance and keep its database selection explicit. These commands are instructions to run on your own machine, not a promise that installation, credentials or DNS are already configured. ### 4.1 Install and review the terms ```bash curl -fsSL https://holmhq.com/install.sh | bash -s -- --mode cli export PATH="$HOME/.local/bin:$PATH" holm --version holm license show ``` `--mode cli` installs only the binary and asks nothing, which suits coding agents and scripts; with no options the installer asks on the terminal. The installer pins v0.262.2 and checks the archive's checksum; check `holm --version` after installing. ### 4.2 Initialize and start a local node Review the licence first. The following ID and digest identify HULA v1; the digest is public licence metadata, not a credential. `holm server init` run without `--accept-license` changes nothing and prints the exact value to pass. ```bash HULA_CONSENT=holm-usage-license-v1@sha256:898163f1d2592ae95c7b722b8b89c9de371bbfe22f96259b030049cd6b250d3e holm server init \ --username dev \ --password '' \ --domain holm.localhost \ --accept-license "$HULA_CONSENT" ``` First-use acceptance belongs to the selected instance (`~/.holm/data.db`, or `--db`). Do not automatically accept terms for a later licence ID or digest. Start the runtime, in the foreground or in the background, and check it: ```bash holm start --runtime-mode manual # Ctrl-C stops it nohup holm start --runtime-mode manual > ~/.holm/holm.log 2>&1 & # or this holm status ``` From 0.248.0, `holm start` and `holm status` register the node as the `local` peer at `http://127.0.0.1:4698`, so `holm app ...` commands reach it with no token to copy. Apps answer at `http://.holm.localhost:4698`, which Chrome, Firefox and Edge open without any DNS setup. ### 4.3 Build and verify a v1 app Build the five-file guestbook in section 10, then: ```bash holm app validate ./guestbook --spec-v1 holm @local app deploy ./guestbook --spec-v1 --host guestbook.holm.localhost holm @local app request GET / holm @local app request GET /api/entries holm @local app logs ``` Use the app ID printed by deploy. Exercise writes, missing pages and browser sign-in too. Stop a foreground server with `Ctrl-C`; service shutdown uses `holm stop --force`. ## 5. Installation ### 5.1 Shell installer The shell installer supports Linux and macOS on `amd64` and `arm64`. It requires `curl`, `tar`, and `sha256sum` or `shasum` for archive verification. It offers a production service, local-development service or CLI binary only. ```bash curl -fsSL https://holmhq.com/install.sh | bash ``` With no options it asks on the terminal. `--mode cli|local|server` asks nothing, for agents and scripts: `local` and `server` also need the person's own `--accept-license @` (without it nothing changes), and `server` needs `--domain` and, for HTTPS, `--email`. `bash -s -- --help` lists them. The installer pins v0.262.2 and platform-specific archive checksums. CLI-only installation needs instance initialization and licence acceptance before substantive instance commands; section 4 shows the order. For an unattended public server install, after reviewing and accepting the terms in section 4: ```bash curl -fsSL https://holmhq.com/install.sh | bash -s -- \ --mode server --domain example.com --email admin@example.com \ --accept-license "$HULA_CONSENT" ``` `--email` selects HTTPS; omit it for HTTP. Server mode installs a system service listening on every interface, not loopback. Configure the firewall and DNS before installing. HTTP installation prints `http://` addresses; HTTPS installation prints `https://`. Section 5.3 describes the generated password and one-time operator peer command. Local mode is a Linux user service, not this public-server setup. ### 5.2 Upgrade the installed binary After accepting the selected instance's terms: ```bash holm upgrade --check holm upgrade holm --version ``` Use the official release channel. Review any changed licence before upgrading. Remote upgrade needs an owner/admin member credential, not just an API key. ### 5.3 Linux service installation With an installed binary, review and deliberately accept its licence before installing a production systemd service: ```bash HULA_CONSENT=holm-usage-license-v1@sha256:898163f1d2592ae95c7b722b8b89c9de371bbfe22f96259b030049cd6b250d3e sudo holm service install \ --domain example.com \ --email admin@example.com \ --https \ --accept-license "$HULA_CONSENT" ``` Service installation selects its own instance. Acceptance in a different operator database is not acceptance for that service. The default service user is `holm`. A production install listens on every interface (`0.0.0.0`); confirm the service's database path, listener and firewall. Without `--https` it serves HTTP and prints `http://` URLs, not an HTTPS address. The install generates a random admin password and prints it for the operator; there is no shared default password. It also prints a one-time command to run on the operator's own computer: ```text holm peer add --url ://admin. --token ``` Use the exact URL, scheme and generated key from the install. `peer add` creates its selected local client database on first use, applies the full schema, and prints the created path on stderr. It does not start a server or create admin credentials. Review `holm license show` and append `--accept-license @` on first use; later peer commands reuse that instance's licence receipt. Only `peer add` creates missing client state; other peer commands still require an existing database. Selection remains `--db`, then `HOLM_DB_PATH`, then `~/.holm/data.db`. A later `holm server init --db ` sets up a local server without losing saved peers; it refuses an already configured server with the appropriate settings commands. Then run `holm peer check ` and `holm @ status` there. Store the password and key securely; do not paste them into app files, reports or shared logs. The installation key is for CLI administration, not member-based remote binary upgrades (section 8). ### 5.4 Official distribution Holm is closed source and distributed under the Holm Usage License. The public installation path is the official binary distribution, not a source checkout. See the effective [licence](https://license.holmhq.com/v1/). ## 6. Node configuration ### 6.1 Select the instance Database selection is: ```text --db > HOLM_DB_PATH > ~/.holm/data.db ``` `server start` accepts temporary `--bind`, `--port` and `--domain` overrides. Use `server set-config` for persistent values. Inspect the selected database without migrations: ```bash holm server status --db ./holm-dev.db ``` ### 6.2 Persistent server fields ```bash holm server set-config --domain example.com holm server set-config --port 4698 holm server set-config --bind 0.0.0.0 holm server set-config --env production holm server set-config --cors-origin https://app.example.com holm server set-config --debug-sql-args=false ``` The command supports `--db` for explicit instance selection. Restart after changing startup network fields. Binding beyond loopback is not a complete public setup: configure DNS, HTTPS, authentication and firewall rules first. Avoid SQL-argument debug logging on a public node. Reset node administration credentials locally with: ```bash holm server set-credentials \ --username admin \ --password '' ``` ### 6.3 Typed settings ```bash holm settings schema --json holm settings list --json holm settings get settings.node.server.domain --json holm settings schema settings.node.providers --json ``` Use the current schema rather than a copied key/default inventory. `settings set ` changes a value; `settings reset ` removes an override. `--file ` reads the value on the calling machine. The resolver uses the running node for local `settings get`/`list` when its loopback peer is reachable, and database/default values when stopped. `settings.node.server.domain` defaults to `holm.localhost`, matching fresh `server init` without `--domain`. Start-time `--domain` and `--port` overrides report source `flag`; they do not overwrite stored settings. `settings.node.server.env` is readable but read-only. A production environment name does not enable TLS: generated app links use `https` only when the node serves HTTPS, otherwise `http`, including its nondefault port. A malformed `https.enabled` reads as off with a warning instead of failing. App settings are rooted at `settings.apps..*`. Author static app identity and web behavior in `holm.json` as section 9 describes; do not infer additional authoring keys from the node settings namespace. Local settings writes need a restart to apply to a running node; peer settings updates apply immediately. ### 6.4 Policy ```bash holm policy schema --json holm policy list --json holm policy get auth.public_signup --app --json holm policy set auth.public_signup false --app holm policy reset auth.public_signup --app ``` Policy supports node and app scope. Use its schema for current gates, limits, units and defaults. A namespace being present in `env.holm` is not permission to use it; app policy and caller checks still apply. For static CSP behavior, follow section 9 and inspect the actual response. `web.headers` is the v1 authoring surface for deliberate static headers. ### 6.5 Runtime environment values `env.holm.env.get(name, fallback)` and `.has(name)` read the current app's saved environment values, not the node process's environment. Use them for non-secret configuration. Set them with `holm [@peer] app env set NAME VALUE`; `app env list ` shows names and values, and `app env unset NAME` removes one. ### 6.6 Two secret stores Runtime secrets are app-readable and scoped to the app: ```bash holm secret runtime set API_TOKEN '' --app holm secret runtime list --app holm secret runtime remove API_TOKEN --app ``` V1 server code reads its own value with `env.holm.secrets.get("API_TOKEN")`. A missing value, including a secret belonging to another app, is `undefined`. Outbound-auth secrets let the node inject a credential without returning its value to app code: ```bash holm secret set API_TOKEN '' \ --as bearer \ --domain api.example.com \ --app holm secret list --app holm secret remove API_TOKEN --app ``` `secret set` also accepts `--as header` or `--as query` with `--key` for the header or parameter name. Prefer the narrow app and domain scope. Secret listings mask values. Do not print credentials in runtime logs or URLs. ### 6.7 Providers Providers are durable settings. Inspect the schema for the family you need, then configure its driver and credentials as that schema permits: ```bash holm settings schema settings.node.providers --json holm settings list settings.node.providers --json ``` AI, email and SMS calls require a configured provider and remain subject to app policy and budgets. App namespace availability alone does not configure one. Configured SMS, Resend/Maileroo and Telegram driver requests may reach their binding's exact public HTTPS hostname without a `net allow` row. This writes no rule and grants no app fetch or outgoing webhook permission. HTTPS, redirects, private-DNS and existing size/rate/timeout guards still apply. #### Send SMS Choose a route, ask the operator for their own consenting test number, then inspect stored receipts. `queued` means durable admission, `accepted` means provider acceptance, not handset delivery. Sending is asynchronous: a receipts call right after a send may still show `queued`; wait a few seconds and read it again. SMS provider and policy settings take effect on a running node without a restart. Replace the sample number, credentials, node origin and binding ID. Keep secrets out of app files, logs and shared transcripts. `holm help sms` and `holm sms --help` contain the complete command guide. **Route 1 — SMSGate cloud relay: recommended for a cloud node.** The Android phone can be anywhere with internet. Install **SMS Gateway for Android** from `sms-gate.app`. Turn on **Cloud server**, start the service, and copy the generated username/password from **Home**: ```bash holm secret runtime set SMS_GATE '{"username":"","password":""}' holm settings set settings.node.providers.sms.send.default '{"driver":"smsgate","config":{"base_url":"https://api.sms-gate.app/3rdparty/v1/messages","auth":"basic","secret_ref":"SMS_GATE"}}' holm sms status --json holm sms send --to '+15555550123' --body 'My SMS setup test' --json holm sms receipts --limit 20 --json ``` Read `bindingId` from status. No `holm net allow` step is needed. Status inspects configuration and exact-scope credential presence; it never probes an account or proves network readiness. Receipts read only stored metadata. For delivery receipts, copy the phone's **Settings → Webhooks → Signing Key**; store it separately from the Basic password, then update the same binding: ```bash holm secret runtime set SMS_GATE_HOOK '' holm settings set settings.node.providers.sms.send.default '{"driver":"smsgate","config":{"base_url":"https://api.sms-gate.app/3rdparty/v1/messages","auth":"basic","secret_ref":"SMS_GATE","webhook_secret_ref":"SMS_GATE_HOOK"}}' holm sms status --json ``` Register three webhooks through SMSGate's published API, one per event. Read the node's main domain (for a remote node, use the same command with `@`): ```bash holm settings get settings.node.server.domain ``` `` means **`https://` plus the domain value** from `holm [@peer] settings get settings.node.server.domain` (use its `Value` field, not the whole formatted output). The receipt hook answers only on this main domain, not the admin host/CLI peer URL and not an app host. Replace it with the full HTTPS origin, and `` with `bindingId`. A local development node without HTTPS answers on `http://:` instead, which is enough to check routing but not for a real phone, which needs HTTPS: ```bash curl -X POST -u ':' -H 'Content-Type: application/json' -d '{"url":"/_holm/sms/status/","event":"sms:sent"}' https://api.sms-gate.app/3rdparty/v1/webhooks curl -X POST -u ':' -H 'Content-Type: application/json' -d '{"url":"/_holm/sms/status/","event":"sms:delivered"}' https://api.sms-gate.app/3rdparty/v1/webhooks curl -X POST -u ':' -H 'Content-Type: application/json' -d '{"url":"/_holm/sms/status/","event":"sms:failed"}' https://api.sms-gate.app/3rdparty/v1/webhooks curl -u ':' https://api.sms-gate.app/3rdparty/v1/webhooks curl -X DELETE -u ':' https://api.sms-gate.app/3rdparty/v1/webhooks/ ``` The **phone itself** calls the hook. It must reach HTTPS with a valid certificate; 2xx within 30 seconds prevents retries. Holm verifies hex `X-Signature` as HMAC-SHA256 over the raw body followed by `X-Timestamp`. Multipart delivery is confirmed only after all actual parts; a REST Delivered observation alone may be `partial`. Coordinate signing-key rotation manually: queued sends retain their binding snapshot, including the signing reference. **Privacy:** message text and numbers pass through SMSGate's service. Their private-server option avoids that relay. Cloud wake-up uses Google push, a held-open-connection fallback and a 15-minute poll; keep the Android service running. **Route 2 — SMSGate on the same network or over USB.** Enable **Local server** in the Android app and start the service. Its default URL is `http://:8080/message`. USB forwarding applies only when the Holm node runs on the computer the phone is plugged into. With Android debugging authorized, run on that computer: ```bash adb forward tcp:18080 tcp:8080 ``` Then configure the local collection URL (also suitable for a loopback fake): ```bash holm secret runtime set SMS_GATE '{"username":"","password":""}' holm settings set settings.node.providers.sms.send.default '{"driver":"smsgate","config":{"base_url":"http://127.0.0.1:18080/message","auth":"basic","secret_ref":"SMS_GATE"}}' holm sms status --json holm sms send --to '+15555550123' --body 'My SMS setup test' --json holm sms receipts --limit 20 --json ``` Loopback needs no LAN switch. For a trusted LAN, substitute the phone's numeric IP: ```bash holm settings set settings.node.policy.sms.allow_lan_http true holm settings set settings.node.providers.sms.send.default '{"driver":"smsgate","config":{"base_url":"http://192.168.1.2:8080/message","auth":"basic","secret_ref":"SMS_GATE"}}' holm sms status --json ``` Then repeat the test send/receipts commands. An enabled node gateway enrolls only its exact IP/port/path; app gateway bindings must match it. DNS resolving to private addresses is not allowed. **Local HTTP is unencrypted; prefer USB loopback or TLS/VPN. Never port-forward the phone API.** A cloud node needs a trusted network or tunnel to reach a LAN phone. Receipts need the separate signing key/reference and three events as above, registered in the phone's local webhook API/settings, with URL `/_holm/sms/status/`. Here too, `` is **`https://` plus the domain value** from `holm [@peer] settings get settings.node.server.domain`, not the admin/peer URL or an app host. The phone must reach that main-domain HTTPS URL with a valid certificate. The node-to-phone USB URL is not a phone-to-node callback address. **Route 3 — Twilio.** Store credential JSON and create the binding. It is unready until its required HTTPS callback URL includes the discovered binding ID. `` in `callback_url` is **`https://` plus the domain value** printed by `holm [@peer] settings get settings.node.server.domain` — the main domain only, not the admin host/CLI peer URL or an app host: ```bash holm secret runtime set TWILIO_SMS '{"account_sid":"","auth_token":""}' holm settings set settings.node.providers.sms.send.default '{"driver":"twilio","config":{"secret_ref":"TWILIO_SMS"}}' holm sms status --json holm settings get settings.node.server.domain holm settings set settings.node.providers.sms.send.default '{"driver":"twilio","config":{"secret_ref":"TWILIO_SMS","from":"+15555550100","callback_url":"/_holm/sms/status/"}}' holm sms status --json holm sms send --to '+15555550123' --body 'My SMS setup test' --json holm sms receipts --limit 20 --json ``` `from` is optional; omit only if the provider selects the trial sender. Trial text, verified recipients and eligible countries are account-specific; use the exact allowed body when required. Whether a trial account can text your country depends on the account; check the Twilio console. The REST host is fixed to `https://api.twilio.com`; no `base_url` or `net allow` step exists. `callback_url` pins the exact external hook URL. Do not add a `delivery_id` query; Holm appends it. Twilio signs the configured URL plus all sorted POST fields with the Auth Token. Coordinate token rotation manually; queued account identity cannot change. Authenticated 21610 rejection records node-wide suppression; removing Holm suppression never clears Twilio's block or revives that original delivery. **Costs and consent:** Holm and SMSGate local/cloud cost nothing (SMSGate may add tiers for new users). Each text bills on the phone's SIM plan; Twilio has its own pricing. Obtain consent, honor opt-outs, and follow sender/DLT rules where required, including Indian commercial sender/template registration. Holm templates are not provider/DLT registration. Holm does not automatically read STOP replies. See the complete app write operation in section 22 and runtime rules in section 12. ### 6.8 Reverse-proxy trust Forwarding headers are ignored for visitor attribution by default. Configure only the reverse proxies that actually connect to Holm: ```bash holm settings set settings.node.server.trusted_proxies '127.0.0.1,::1' holm settings reset settings.node.server.trusted_proxies ``` The setting accepts IPs, CIDRs or the built-in `cloudflare` preset. It changes visitor attribution and rate limits, not local-only authorization. Do not let a public proxy pass a `localhost` Host header through, and do not put a dev-login-enabled node behind one. Read `holm settings --help` for the forwarding-header rules before enabling trust. ### 6.9 Page for hosts with no app From 0.251.0, an unknown host gets a built-in "No app at this address" page with `settings.node.brand.name` and `settings.node.brand.symbol`, or Holm's brand when unset. Replace it with a complete, self-contained HTML document: ```bash holm @prod settings set settings.node.pages.unknown_host --file ./no-app.html --json holm @prod settings reset settings.node.pages.unknown_host --json ``` The page is UTF-8, at most 64 KiB, and served as-is with status 404. Inline its styles and images: a host with no app serves no companion files. Clients asking for `application/problem+json` keep the JSON answer; a flood of unknown names uses cached inline branding instead. The built-in page carries node colours and a trusted node-home link even for unrelated hosts, never a link derived from their Host. This setting does not create a host route or grant DNS/TLS permission. ## 7. Node lifecycle and operations ### 7.1 Start and stop ```bash holm start holm start --runtime-mode service holm start --runtime-mode manual --db ./holm-dev.db holm stop --force holm restart ``` `start` defaults to `auto`: prefer a detected service, otherwise run a foreground process. `service` requires a service; `manual` runs in the terminal. Stop a manual foreground process with `Ctrl-C`. Service stop is explicitly confirmed with `--force`. The lower-level foreground command accepts its own database and listener: ```bash holm server start \ --db ./holm-dev.db \ --bind 127.0.0.1 \ --port 4698 \ --domain holm.localhost ``` Do not start a second server or an offline Object operation on the same instance. A stopped remote node may need direct host access to start again. ### 7.2 Status ```bash holm status holm server status holm @prod status holm system stats --format json ``` Runtime status reports service/manual detection and local/advertised health checks. `server status` opens the selected database read-only and does not run migrations. These are different inspections; use the one you need. ### 7.3 Logs ```bash holm logs -n 100 holm @prod logs holm @prod app logs --since 1h --json holm activity list --since 24h holm activity stats ``` `logs` reads service logs. `app logs` reads app execution logs and supports `-f`, `-n`, `--limit`, `--since`, `--member` and `--json`. `activity` is the activity stream, not the service log. `holm [@peer] app analytics purge [--path-prefix P | --path P] [--since T] [--until T] [--apply] [--json]` and `app logs purge [--match TEXT] [--level L] [--since T] [--until T] [--apply] [--json]` default to exact-count dry runs; analytics needs a path or time filter, logs a match or time filter (RFC 3339 or Unix ms). Node admin/owner or `system:*` authority is required through the authenticated peer API; apply deletes in bounded batches and audits app, caller, filter kinds/byte lengths and counts, never paths or log text. Time-filtered analytics purges erase whole overlapping path-keyed hourly/daily rollup rows, losing that path's partial-bucket aggregates outside the range; other paths/apps survive, new buffered events may arrive, and backups are not scrubbed. ## 8. Peers and remote operation Create a peer with an exact admin URL and a credential for the intended work: ```bash holm peer add prod \ --url https://admin.example.com \ --token '' ``` ```bash holm peer list --format json holm peer check prod holm peer default prod holm peer update-token prod --token '' holm peer remove prod ``` Use an explicit prefix: ```bash holm @prod status holm @prod app list holm @prod app deploy ./my-app --spec-v1 --host app.example.com holm @prod logs holm @prod upgrade --check ``` If only one peer is configured, it becomes the default. Bare app commands may use that default. Do not rely on it when a wrong destination could matter. If a v1 deploy reports `DEPLOY_FOLLOW_UP_FAILED`, its files are already committed: do not rerun the same deploy, which would create a second app. The CLI prints the command that finishes the step, for example `holm host add --app `. API clients redeploy the same app with the error details' `app_id` and `force=true`. Host-route creation and primary selection are all-or-nothing; busy route setup is retried within the request deadline, up to 30 seconds. `@peer` selects a destination; it does not make every command remote-capable. Direct service installation and server initialization need host access. Peer liveness and admin authorization are separate. Liveness is `healthy`, `timeout` (no answer within 5 seconds; slow, not necessarily down) or `unreachable`; `peer list` shows the reason. `peer check` can report `authenticated`, `no-token`, `invalid-token`, `insufficient-scope` or `unknown`. A healthy node does not prove your token has the required role. Peer listings show safe token metadata and fingerprints, not raw tokens. Startup creates or refreshes the `local` peer at a loopback address. On HTTPS nodes it retains the admin identity for SNI, certificate verification and the request Host; requests do not need public DNS or a CDN. `holm system stats` and `holm peer list` therefore reach the local HTTPS node without disabling verification. An explicit `holm start --runtime-mode manual --port ` also applies to HTTPS; without a port flag HTTPS still listens on 443. For remote upgrades, create an admin member credential on the target node with existing authority, then store that PAT in the peer. Scoped API keys, including `system:*`, cannot replace the remote binary. ## 9. App model Check the machine before writing code. Stop and tell the human what is missing if any prerequisite fails: ```bash holm --version holm peer list holm @ status holm @ host list --limit 1000 ``` The v1 guide requires CLI 0.241.0 or newer and node 0.242.0 or newer for realtime and formData. This reference describes 0.262.2. If the requested domain is already listed, stop and ask before replacing anything. Never use `--force` on a domain you did not deploy yourself in this task. Ask for the peer URL and deploy token if they are missing; never invent a token. From 0.250.0, `holm [@peer] app install ///[@ref] [--host ]` fetches a ready-to-deploy v1 folder (`holm.json` is optional); no build runs. `@vX.Y.Z` selects `/vX.Y.Z`, a hexadecimal ref selects a commit, and other refs select a branch or tag. Without a ref, the root `index.json` app entry's `tag` wins when present; otherwise the default branch is used and reported as unpinned. Holm checks any matching index app/version `h1` and pins the release set (all app-relative regular files, including README/PROMPT/scripts, minus hidden paths except root `.well-known/`, `node_modules`, `*.log` and `publish.exclude`) with Go Hash1. Deployment still uses the narrower normal v1 file set. As in Go, only version tags pin (`@vX.Y.Z`, `@/vX.Y.Z` or the index tag; they resolve only as tags); other names, branches or tags like `stable`, move and are reported as unpinned. The node keeps address, ref/version, commit and `h1`; changed bytes for a known version tag refuse with both checksums and no changes. Install reuses its app id, refuses another app's occupied host, and prints the address, full commit, version/ref, `h1`, app id and host. These are checksum pins, not signatures; `app upgrade` is disabled. ### 9.1 The app folder ```text my-app/ holm.json optional: name, title, description, colour, web settings web/ public files, served as-is (index.html, CSS, JS, images, 404.html) api/main.js optional server code: export default { fetch(request, env) } api/* private modules, JSON, HTML templates or any other file type ``` - With a `web/` or `api/` folder, only `web/` is public. Without either folder the whole app folder is the public site ("plain mode"), which suits a purely static site. - `holm.json` and everything under `api/` are never served as files. - Dot files and source maps are left out automatically. Everything else in `web/` is served as it is. In plain mode, root `node_modules/`, `test/`, `tests/`, `package.json`, `AGENTS.md` and similar tooling entries are left out too. - The node runs no build. If you use a bundler or framework, build first and deploy a folder in this shape with the build output in `web/` (and a bundled `api/main.js`). - Limits: 10,000 files, 64 MiB per file, 256 MiB in total. ### 9.2 holm.json ```json { "name": "guestbook", "title": "Guestbook", "description": "Leave a short note for the next visitor.", "themeColor": "#0f766e", "web": { "spa": false, "redirects": [{ "from": "/old-page", "to": "/", "status": 308 }], "headers": [{ "path": "/assets/*", "set": { "Cache-Control": "public, max-age=31536000" } }] } } ``` - `name`: lowercase ASCII letters, digits and single hyphens, 1 to 48 characters; no edge hyphen or `--`. - `title`, `description`, `themeColor`: Holm adds missing title, description, theme colour, canonical and social-card head tags to static `web/` HTML only (including plain-mode files). It does not inject HTML returned by `api/main.js`; write those tags yourself. Authored tags are never changed. Set `"web": { "inject": { "head": false } }` to turn injection off. - `darkThemeColor` is not an accepted `holm.json` field: it follows the app's `themeColor` when set, otherwise the node's dark accent. To set a different dark colour, author `` in your static HTML. - Holm supplies a default `og:image` social card. To use your own, deploy an image in `web/` and author an `og:image` meta tag with its absolute public URL in the page's ``. - `web.spa: true`: unknown extensionless paths serve `index.html` (single-page apps). - Every field is optional; an empty or missing `holm.json` is fine. ### 9.3 Static files - `/about` serves `web/about.html`; `/docs/` serves `web/docs/index.html`. Other spellings (such as `/about.html`) redirect to the canonical address with 308. - A miss serves the nearest `404.html` (for example `web/docs/404.html`, then `web/404.html`) with status 404. Without an authored page, Holm renders a self-contained branded 404 from app → node → Holm identity without writing files or injecting scripts. SPA and a returned `fetch` response stay final. Visiting `/404` directly also returns 404, not a successful page. - These paths belong to the node and never reach your app: `/auth/*` (sign-in pages), `/_holm`, `/_ws` (realtime WebSocket) and `/.well-known/holm`. Do not create files or routes there. - Static `text/*`, JavaScript, JSON and SVG responses use UTF-8 unless `web.headers` sets `Content-Type` for that path. - Static pages carry a Content-Security-Policy. Same-origin scripts and API calls work; other resource origins must be allowed by the policy. V1 apps retain their approved iframe origins, including embedded players; inspect the response header and browser console if a resource is blocked, and use `web.headers` for a deliberate policy. HTML returned by `api/main.js` needs its own CSP and other headers. - App responses support HTTP byte-range requests, so media players can seek rather than download a whole file before playback. ### 9.4 App identity From 0.245.0, an app's icons come from one symbol. Draw the mark once as `holm.symbol.svg` at the app root: a single `` filled `#000000` on transparent, in `viewBox="0 0 24 24"`, with no stroke, text, group, colour or transform. The path's tight bounds fill the 24 × 24 square on their longer side and are centred on the shorter one. The file is one line in a fixed form: absolute uppercase commands, single spaces, at most six decimal places and a final line feed: ```svg ``` Holm keeps the symbol private, and deploy refuses any other form, naming the rule broken. Where only the spelling differs, the error shows the exact text to write. From 0.247.0, `holm app identity symbol icon.svg ./my-app` writes that file from a simple filled icon: path, rect, circle, ellipse and polygon shapes in one colour, with groups and transforms. It refuses strokes, text, images, masks, gradients, translucency and more than one colour, naming what it found, and `--force` replaces a different symbol. It never fetches anything. A good starting shape comes from a permissively licensed filled icon set on Iconify, such as Material Design Icons (`mdi`, Apache-2.0), Phosphor's `-fill` icons (`ph`, MIT), Fluent's `-filled` icons (`fluent`, MIT) or Bootstrap's `-fill` icons (`bi`, MIT); skip logo sets and anything needing attribution, and keep the set's licence notice with your source: ```bash curl -fsS 'https://api.iconify.design/mdi/rocket-launch.svg' -o icon.svg holm app identity symbol icon.svg ./my-app ``` Then generate the web identity set, and look at `icon-512.png`: the symbol check cannot tell a hole from a fill. ```bash holm app identity generate ./my-app ``` It draws the symbol on the app's `themeColor` and writes `favicon.svg`, `favicon.png`, `favicon.ico`, `apple-touch-icon.png`, `icon-192.png`, `icon-512.png`, `icon-maskable-512.png`, `manifest.webmanifest` (from `title`, `shortName` and `themeColor`), the 1200 × 630 `social_preview.png`, and an inline branded `404.html`. An existing or edited 404 remains yours and wins. From 0.246.0 the icons are styled: `"iconStyle"` in `holm.json` picks the look, one of `glass` (the default), `soft`, `flat`, `deep`, `duotone`, `grain`, `pattern`, `paper` or `night`. Every shade comes from `themeColor`, and the app's name nudges the gradient's angle and fill, so a node's apps do not all look alike. Missing symbol and `themeColor` independently use the explicitly selected local node DB (`HOLM_DB_PATH`), then Holm; without that selection no node is consulted and no peer is contacted. A symbol on a colour too pale for white is drawn in deep ink. Generate the node's stored set from its settings, without an app folder: ```bash holm settings set settings.node.brand.name "My node" holm settings set settings.node.theme.accent '#0f766e' holm settings set settings.node.brand.icon_style glass holm node identity generate --format json holm presentation inspect --app --format json ``` The node uses `brand.symbol` and `brand.subtitle` too; a missing symbol uses Holm's. Admin/member icons follow changes automatically; explicit asset URL settings still win. Public assets live at `/node-identity/` on admin/member hosts only. App head fallbacks use absolute admin URLs; inherited manifests install the app, not the admin origin. Inspect is read-only and reports effective symbol owner/hash, colour, generated owner/digest and page source, never private symbol bytes. The generator writes into `web/` for an app or the root for a plain site. An app that a build tool such as Vite assembles (`package.json` and `public/`, no `web/` yet) gets the files in `public/`, recorded under `web/`, where the build copies them; have the build copy `holm.symbol.svg` and `holm.generated.json` beside `holm.json`. Each file's hash goes in root `holm.generated.json`. It never overwrites a file it did not record, or one changed since: that file is yours. Run it again after changing the symbol, colour, style or title, and deploy both the files and the record. On deploy, Holm links the recorded files whose bytes still match in each page's head: the icons, the touch icon, the manifest and the social card. Tags you write yourself always win. ### 9.5 Validate, deploy and verify ```bash holm app validate ./my-app --spec-v1 holm @ app deploy ./my-app --spec-v1 --host app.example.com ``` - The deploy prints the app id (`holm_app_...`). Keep it for logs and reports. - Validate checks the local folder; it does not deploy or run the app. Exercise your routes after deploying to check runtime behavior. - To ship a change, run the same deploy command again with `--force`. The whole file set switches at once, and a failed deploy leaves the previous version serving. A warning that the host is already registered is expected when redeploying your own host. - `--spec-v1` is required every time. Without it the node refuses (`V1_APP_REQUIRES_SPEC_V1`). - Verify like a visitor would: `curl -i https://app.example.com/`, each API route, a missing page (expect 404), and the page in a browser if you have one. If the domain does not resolve, tell the human its DNS must point to the node. Read-only debugging, with the app ID printed by deploy: ```bash holm @ app ds find notes --limit 50 holm @ app kv list holm @ app request GET / holm @ app logs ``` Common refusals: `V1_INGEST_INVALID` (folder or `holm.json` problem; the message names the path), `V1_PUBLIC_PATH` (a file claims a reserved path) and `V1_FEATURE_LATER` (a feature not available yet was used). At runtime: `V1_ENTRY_INVALID` (`api/main.js` does not export `{ fetch }`), `V1_RESPONSE_INVALID` (fetch did not return a `Response`) and `V1_FETCH_FAILED` (it threw; see the logs). ### 9.6 Report back Tell the human: the URL, the app id, what you built, how you verified it, and anything you left out because it is not available yet. ## 10. Build a complete app from scratch Five files. Deploy with `holm @ app deploy ./guestbook --spec-v1 --host guestbook.example.com`. `holm.json` ```json { "name": "guestbook", "title": "Guestbook", "description": "Leave a short note for the next visitor.", "themeColor": "#0f766e" } ``` `web/index.html` ```html

Guestbook

    ``` `web/style.css` ```css body { font: 16px/1.5 system-ui, sans-serif; margin: 0; padding: 2rem 1rem; } main { max-width: 36rem; margin: 0 auto; } form { display: grid; gap: .5rem; margin-bottom: 1.5rem; } input, textarea, button { font: inherit; padding: .5rem; } li { margin-bottom: .75rem; } ``` `web/app.js` ```js const list = document.querySelector("#entries"); const form = document.querySelector("#sign"); async function load() { const entries = await (await fetch("/api/entries")).json(); list.replaceChildren(...entries.map((e) => { const li = document.createElement("li"); li.textContent = `${e.name}: ${e.message}`; return li; })); } form.addEventListener("submit", async (event) => { event.preventDefault(); const data = Object.fromEntries(new FormData(form)); const res = await fetch("/api/entries", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(data), }); if (res.ok) { form.reset(); load(); } else { alert((await res.json()).error); } }); const me = await (await fetch("/api/me")).json(); const status = document.querySelector("#me"); if (me.member) { // Sign-out is a POST, so it is a button: a link (a GET) leaves the visitor signed in. const signOut = document.createElement("button"); signOut.type = "button"; signOut.textContent = "Sign out"; signOut.onclick = async () => { await fetch(me.logoutURL, { method: "POST", headers: { accept: "application/json" } }); location.reload(); }; status.append(`Signed in as ${me.member.name || me.member.email || "a member"} · `, signOut); } else { const link = document.createElement("a"); link.href = me.loginURL; link.textContent = "Sign in"; status.append(link); } load(); ``` `api/main.js` ```js const json = (body, status = 200) => Response.json(body, { status }); export default { async fetch(request, env) { const { pathname } = new URL(request.url); const { app, auth } = env.holm; if (pathname === "/api/entries" && request.method === "GET") { return json(app.ds.find("entries", {}, { limit: 50, order: "desc" })); } if (pathname === "/api/entries" && request.method === "POST") { let input; try { input = await request.json(); } catch { return json({ error: "Send JSON." }, 400); } const name = String(input.name ?? "").trim().slice(0, 40); const message = String(input.message ?? "").trim().slice(0, 280); if (!name || !message) return json({ error: "Name and message are required." }, 400); const id = app.ds.insert("entries", { name, message }); return json(app.ds.findOne("entries", id), 201); } if (pathname === "/api/me") { return json({ member: auth.getMember(), loginURL: auth.getLoginURL("/"), logoutURL: auth.getLogoutURL(), }); } if (pathname.startsWith("/api/")) return json({ error: "Not found." }, 404); return env.holm.web.fetch(request); }, }; ``` ## 11. Serverless JavaScript runtime ### Named JSON operations and keyed retries Use CLI and node 0.254.0 or newer for this operations section. A v1 app exports a plain JSON-literal `contract` in `api/main.js` (no spreads, calls or computed values), or the same object in `api/contract.json`, with named handlers in `api/main.js`. Handlers receive native `(input, env, ctx)` and return the declared JSON value, not a `Response` or HTTP envelope. Keys use lowercase dot-separated parts such as `message.get` (`holm.` is reserved). The default route is `POST /api/`; flat, nonsensitive reads may also derive GET. An authored `http` block replaces those routes. This tiny message board deliberately lets **anyone overwrite its public message**. For private writes use `auth:"member"` or `"admin"`, not this demo's `"public"`. Create `holm.json` to keep this demo HTTP-only and disable generated web: ```json {"connectedAgents":false,"surfaces":{"web":{"renderer":"off"}}} ``` `api/main.js`: ```js export const contract = { operations: { "message.get": { summary: "Read the public message", auth: "public", risk: "read", input: { type: "object", properties: {}, additionalProperties: false }, output: { type: "string" }, handler: "getMessage" }, "message.set": { summary: "Replace the public message", auth: "public", risk: "write", idempotency: "required", input: { type: "object", properties: { text: { type: "string", maxLength: 80 } }, required: ["text"], additionalProperties: false }, output: { type: "string" }, handler: "setMessage" } } }; export function getMessage(input, env, ctx) { return env.holm.app.kv.get("message") ?? ""; } export function setMessage(input, env, ctx) { env.holm.app.kv.set("message", input.text); return input.text; } ``` Validate with `holm app validate ./message-board --spec-v1`, then deploy with `holm @ app deploy ./message-board --spec-v1 --host `. On a loopback scratch node at port 7900 with host `message.holm.localhost`, these calls return 200: ```bash curl -sS http://127.0.0.1:7900/api/message.set -H 'Host: message.holm.localhost' \ -H 'Content-Type: application/json' -H 'Idempotency-Key: message-1' \ --data '{"text":"Hello"}' # "Hello" — repeat this exact call to replay without another write. curl -sS http://127.0.0.1:7900/api/message.get -H 'Host: message.holm.localhost' \ -H 'Content-Type: application/json' --data '{}' # "Hello" ``` The same calls work at your deployed app URL without the scratch Host header. Synchronous read/write/destructive operations ship. Reads cannot mutate storage or send email. Member calls need a signed-in human with the target Space's `app.read`; admin means node owner/admin. Unsafe cookie calls require same-origin Origin/Referer. Missing auth/risk disables an operation; it never defaults to public. `auth:"user"` is an explicit, broader audience: anyone signed in as a human on the node may call it without Space `app.read` or node-admin rights. Accounts are node-wide and public sign-up is on by default, so the app's code must check who is calling before returning private data or allowing an action. Use the caller identity already provided in `ctx.subject.member` or `env.holm.auth.getMember()` for workspace, invitation or ownership checks. This level does not change `auth:"member"` or `auth:"admin"`, and credential scopes, inactive-account checks and the human-only requirement still apply. The same audience and consent rules apply to MCP tools built from these operations. Errors use `{ "error": { "code": "...", "message": "...", "details": ... } }` (`details` is optional). Branch on status/code, not display messages: - **400** `INVALID_JSON` / `INVALID_REQUEST`: malformed JSON, schema-invalid input, bad key header, or a key supplied to `idempotency:"none"`. - **401** `UNAUTHORIZED`: invalid credentials or a nonpublic call without a signed-in person. - **403** `FORBIDDEN`: insufficient authority, app-role browser/API calls or failed origin checks. - **404** `NOT_FOUND`: disabled operations or marked misses. With no `fetch` export, unmatched unmarked routes also return 404, but need not use the operation JSON envelope. - **409** `CONFLICT`: same key with changed normalized input/live operation digest, an in-progress call or a crash-unknown outcome. - **428** `IDEMPOTENCY_REQUIRED`: required key absent (omit it on `message.set` to see this). - **428** `CHALLENGE_REQUIRED` / **429** `RATE_LIMITED`: `limits.challenge` or `limits.rate` refused the call (from 0.257.0; see below). - **503** `OPERATION_UNAVAILABLE` / `OPERATION_INGRESS_BUSY`: durable keyed admission unavailable/full, or concurrent ingress full; admission fails before the handler. - **500** `MEMORY_LIMIT_EXCEEDED`: the handler ran past the node's JavaScript memory budget; the app log names the operation and the limit (see "Stay within the memory budget"). Other guards include method 405, stale digest marker 412, timeout 408, size/work 413, media 415 and output/handler failure 500; these are not a reason to blindly retry effects. Send one visible ASCII `Idempotency-Key` value (1–256 bytes, no whitespace or comma lists). `required` demands it; `optional` permits unkeyed execution with no replay guarantee; `none` rejects it. A durable claim precedes execution. Completed matching status, allowlisted headers and body replay for 24 hours after completion, including restart, under **current** authority and the resolved caller partition. Reads do not extend expiry. After expiry the key can execute again. In-progress/crash-unknown claims never automatically expire or reexecute. This is not exactly-once effects; persistence failure cannot report success. Keys are header-only, not identity, body/query fields or digest markers. Write/destructive handlers may call `env.holm.email.send` under current `email.app_send` policy using the canonical sender/provider/egress path; keyed replay does not resend. Deployment neither enables email nor claims a sender. This is outbound send only. Schedules/cron, public/connected/narrowed jobs, operation uploads/multipart, spend, nonempty invocation capabilities and generated web remain refused. Approval/evidence/effect policy fields are unsupported, not silently granted. Raw fetch remains a separate surface. ### Typed HTTP jobs and declared polling This subset needs a binary implementing typed HTTP jobs. It is not JS background work or long compute: execution still has a 5 s execution lifetime and 10 s outer budget. Only user/member/admin operations using an exact-host human session or unrestricted member PAT may admit jobs. Public, connected, narrowed/Space/service and personless producers, JS enqueue/spawn and all schedules/cron stay refused; MCP still omits job operations. A small private inbox-report backend (`api/main.js`): ```js export const contract = { operations: { "reports.refresh": { summary: "Refresh my inbox report", auth: "member", risk: "write", mode: "job", idempotency: "required", job: { statusOperation: "reports.status" }, input: { type: "object", properties: { projectId: { type: "string" } }, required: ["projectId"], additionalProperties: false }, output: { type: "object", properties: { total: { type: "integer" } }, required: ["total"], additionalProperties: false }, handler: "refresh" }, "reports.status": { summary: "Read my report job", auth: "member", risk: "read", input: { type: "object", properties: { jobId: { type: "string" } }, required: ["jobId"], additionalProperties: false }, output: {}, http: { methods: ["GET"], path: "/api/reports/:jobId" }, handler: "status" } } }; export function refresh(input, env, ctx) { const project = env.holm.app.ds.findOne("projects", { id: input.projectId }); if (!project || project.ownerId !== ctx.subject.member.id) throw new Error("Project unavailable"); const report = { total: env.holm.app.ds.count("items", { project: project.id }) }; env.holm.app.kv.set("reports:" + project.id, report); return report; } export function status(input, env, ctx) { return env.holm.app.jobs.get(input.jobId); } ``` Use the HTTP-only `holm.json` above. The status operation must be enabled, synchronous read, have a JSON-returning GET and exactly one `jobId` capture, with no additional required input. It uses `user`, `member` or `admin`; an admin job still needs an admin status operation. Polling never waives the original operation's audience check. Its object/string input must accept every opaque 70-character ID: no enum/const/references/combinators at those sites; the exact `^v1job_[0-9a-f]{64}$` pattern is also supported. Unsupported shapes report `jobs.public`, `jobs.credential`, `jobs.idempotency` or `jobs.status` at deploy. Deployment never enables work. The owner grants it explicitly: ```bash holm @ settings set settings.apps..policy.app.jobs.enabled true curl -sS "$APP/api/reports.refresh" -H "Authorization: Bearer $PAT" \ -H 'Content-Type: application/json' -H 'Idempotency-Key: report-1' \ --data '{"projectId":"myproject"}' # 202 (or declared successStatus:201): {"jobId":"v1job_...","statusPath":"/api/reports/v1job_..."} curl -sS "$APP$STATUS_PATH" -H "Authorization: Bearer $PAT" # The declared read returns {jobId,status,result?}, not the frozen admission receipt. ``` The pending job, key and canonical 202/201 receipt plus `Location` commit atomically, without running the handler on the admitting request. Every matching duplicate keeps that identical receipt, including while running/failed/unknown; changed input/digest is 409 with no new job. The handler's output schema validates eventual output, not 202. `jobs.get` is native JSON and only usable through the declared status operation in the same app + original caller partition under current credentials and both the status and original operation's auth rules. `user` requires node-wide human sign-in; `member` still requires Space `app.read`; `admin` requires node owner/admin. Another signed-in person cannot poll your job, and a broader status audience cannot widen result access. Foreign/missing IDs are uniformly refused; no input, authority or internal error details are projected. Only `get` exists, never enqueue/cancel/retry. A result appears only after successful completion persistence; `failureCode` is safe and has no thrown details. One author-code attempt per retained identity; an app error/timeout may leave partial effects and never triggers a handler retry. Only pre-start capacity/SQLite contention backs off. Disconnect after committed admission does not cancel work. Crash/unpersisted outcomes stay running with `outcome:"unknown", stale:true, recovery:"none"`, report-only. Pending lifetime is 24 hours; terminal receipt/key/result retention is 24 hours after completion; unknown keys never age out. Successful redeploy cancels old pending jobs; started work finishes on starting bytes. There is no running cancellation or recovery. Initial limits: 1 live job/app, 4/node; 100 nonterminal rows/app, 1000/node; 1000 retained keys/app, 10000/node. Input ≤64 KiB (lower route limits win), result ≤1 MiB; shared job/replay bytes ≤64 MiB/app and 256 MiB/node. Lower policy wins; full/off admission is 503 `JOBS_UNAVAILABLE` before author code. Owners/admins inspect with `holm @ app jobs --limit 10 --json` or `app job --json`; `app job cancel` is pending-only. ### Protect public operations from bots From 0.257.0, a public operation (sign-up, contact form, paste) can ask Holm to refuse bots before the handler runs. Add either limit, or both, to the operation: ```js "accounts.create": { summary: "Create an account", auth: "public", risk: "write", limits: { rate: { requests: 10, per: "1m" }, challenge: { work: "30s" } }, input: { type: "object" }, output: { type: "object" }, handler: "createAccount" } ``` - `rate`: `requests` 1–10000 per `per` (`"30s"`, `"1m"`, `"1h"`, at most `"24h"`), per caller: the signed-in person, else the client address (IPv6 by /64). Bursts of up to `requests`, then refills evenly. Past it: **429** `RATE_LIMITED` with `Retry-After` seconds. Counters live in the node's memory and reset when it restarts. - `challenge`: `work` is whole seconds, `"1s"`–`"300s"`: the expected solve time in a browser on one ordinary core (spread over several cores, a page finishes sooner). Use about `"1s"` for a form post and `"30s"` for account creation. `skipMembers: true` lets signed-in people through without solving. A call without a valid proof gets **428** `CHALLENGE_REQUIRED`, and `error.details` holds a fresh challenge. - A solved challenge works for one call. Retrying a keyed call with the same `Idempotency-Key` may reuse it, so a retry still gets the stored reply. Every call that reaches the rate limit spends a token, keyed retries included. A challenge expires 10 minutes (plus four times the work) after it is issued, or when the node restarts. - MCP tool calls cannot solve challenges; with `skipMembers: true`, signed-in agents connected through Agent Connect still get through. **In a page**, Holm serves the solver from your app's own domain: ```js import { fetchChallenge, solve } from "/_holm/challenge.js"; const challenge = await fetchChallenge("accounts.create"); // start while they type const proof = await solve(challenge, { onProgress: (p) => (bar.value = p) }); // 0..1 await fetch("/api/accounts.create", { method: "POST", body: JSON.stringify(input), headers: { "Content-Type": "application/json", ...proof }, }); ``` **From an agent or script**: get a challenge with `GET /_holm/challenge?operation=` (or from the 428's `error.details`): `{ "challenge", "bits", "parts", "expiresAt" }`. For each part `i` from 0 to `parts - 1`, find a decimal nonce `n` (no sign, no leading zeros) where SHA-256 of the UTF-8 text `::` starts with `bits` zero bits, counting from the most significant bit of the first byte. Send the call with `Holm-Challenge: ` and `Holm-Challenge-Solution: n0,n1,…` (comma-separated, no spaces): ```python import hashlib, json, urllib.request base = "https://example.zyt.app" agent = {"User-Agent": "my-agent/1.0"} # name your client; see below get = urllib.request.Request(base + "/_holm/challenge?operation=accounts.create", headers=agent) c = json.load(urllib.request.urlopen(get)) def solve(part): n = 0 while int.from_bytes(hashlib.sha256(f"{c['challenge']}:{part}:{n}".encode()).digest(), "big") >> (256 - c["bits"]): n += 1 return str(n) proof = ",".join(solve(i) for i in range(c["parts"])) request = urllib.request.Request(base + "/api/accounts.create", data=b"{}", method="POST", headers={**agent, "Content-Type": "application/json", "Holm-Challenge": c["challenge"], "Holm-Challenge-Solution": proof}) print(urllib.request.urlopen(request).read()) ``` Send a `User-Agent` that names your client. Nodes behind a CDN often refuse library defaults: Cloudflare's browser integrity check answers Python's `Python-urllib/3.x` with **403** and `error code: 1010` on every path, before Holm sees the request. ### Use your app from ChatGPT or Claude Connect ChatGPT or Claude's MCP connector to `https:///_holm/mcp`. For connected user/member/admin tools and client admission troubleshooting, see [the app MCP sign-in guide](workflows/app-mcp.md). Use your app's domain, not the node domain or an app-ID URL. Holm owns this reserved path; you do not write an MCP route. It calls your v1 operations through the same guards. The connection presents your app: `holm.json`'s `name`, its `title` and `description` (or the app's brand settings), its address, and its icon (section 9.4), so hosts that show server icons show yours. For a tiny greeting app, set `holm.json` to `{"surfaces":{"web":{"renderer":"off"}}}` and put this in `api/main.js`: ```js export const contract = { operations: { "hello.get": { summary: "Say hello", auth: "public", risk: "read", handler: "hello", input: { type: "object", properties: {}, additionalProperties: false }, output: { type: "string" } } } }; export function hello(input, env, ctx) { return "Hello"; } ``` Deploy with `--spec-v1` on your app domain. `hello.get` appears as the tool `hello_get`. Only enabled synchronous operations with both `auth.invoke.public:true` and `auth.discover.public:true` appear; `auth:"public"` supplies both. Inputs must be objects; names replace dots with underscores and must be unique and at most 64 characters. Nonpublic user/member/admin tools require sign-in and consent. Access tokens last five minutes; refresh lasts seven days idle, with a thirty-day absolute limit. Frontend-only redeploys, description, display-group and input/output schema changes keep existing tokens and client registration working. Changes confined to ungranted scopes do too. Adding or removing a granted operation, changing its risk, invoke/discovery auth requirement or sensitivity, or changing granted scopes or the consent ceiling ends the affected connection rather than silently narrowing it. Losing member access, changing the app's canonical route identity or node domain/port/HTTPS identity, disabling connections, or disconnecting also ends it. Restoring an earlier identity never revives its old consent, authorization codes or tokens. Older connections need one new sign-in. For an available app at the same origin, an ended connection returns `401 invalid_token` on tool requests and `400 invalid_grant` on refresh; reconnect to review and approve the current actions. If operation authority changes between the review screen and Allow, Holm refuses that approval without issuing a code; review the current actions again. An unchanged-contract redeploy does not interrupt approval. For MCP tools with `idempotency:"required"` or `"optional"`, Holm adds one optional string argument, `idempotency_key`: "A fresh random value for each new action; reuse it only when retrying the same action." Use 1–256 visible ASCII characters, with no whitespace or commas, just like the `Idempotency-Key` header. Holm removes the argument before operation validation and uses it as the key; an HTTP `Idempotency-Key` header, when also present, wins. Repeat the same key and arguments to replay; changed arguments with that key return status 409. Required-key tools without either key source fail before execution with status 428 and `IDEMPOTENCY_REQUIRED`. The same 24-hour replay rules apply; queries cannot supply keys. Tools without keyed idempotency get no reserved property and reject `idempotency_key` as an unknown argument. If the operation input already declares `idempotency_key`, Holm does not add or extract it: that tool stays header-only and the field remains ordinary operation input. Plain HTTP operation routes are unchanged: their keys still come only from the header. Results include full JSON text and the same object in `structuredContent`: this greeting returns `{"status":200,"value":"Hello"}`. No-content success is `{"status":204}`; failures contain `status` and `error` (`code`, `message`, optional `details`) and set `isError:true`. Text is not shortened. This is POST JSON only: no streaming, sessions or credentials. `"connectedAgents":false` in `holm.json` turns MCP off (404). ### Raw Fetch handler ```js export default { async fetch(request, env, ctx) { const url = new URL(request.url); if (url.pathname === "/api/hello") { return Response.json({ hello: "world" }); } return env.holm.web.fetch(request); // static files, redirects, SPA and 404 pages }, }; ``` - Unmatched markerless `/api` requests reach exported `fetch`; registered operations and their guard failures do not. Other requests reach `fetch` when no static file or redirect answered, including `POST`, `PUT`, `DELETE` and raw browser `OPTIONS` preflights. - Return a standard `Response`. Your response is final, so set every header you need yourself (content type, cache, CORS). The node adds none. - End with `return env.holm.web.fetch(request)` so the app's pages, SPA routes and `404.html` still work for everything your code does not handle. - Read bodies once with `await request.json()`, `request.text()`, `request.arrayBuffer()` or `request.formData()` (URL-encoded and multipart HTML forms). Use a form's `get` or `getAll` for fields and file uploads (files provide `name`, lowercased `type`, `size`, `lastModified`, `text()` and `arrayBuffer()`). A file is immutable; every `arrayBuffer()` returns an independent copy, and `text()` decodes UTF-8 with replacement and removes a leading BOM. Multipart file bytes are the wire bytes; Content-Transfer-Encoding does not transform them. - Modern JavaScript works: ES modules with top-level `await`, private class fields, `??=`, `for await` and async generators. - Regular expressions support Unicode property escapes with the `u` flag: General_Category, Script and Script_Extensions values and the binary properties such as `\p{XID_Start}`, `\p{Alphabetic}` or `\p{Emoji}`, from Unicode 15.0 tables. - Import your own files with relative paths: `import { x } from "./lib.js"`. Package imports such as `import { Hono } from "hono"` do not resolve: bundle dependencies into `api/main.js` first (for example `esbuild src/main.js --bundle --format=esm --outfile=api/main.js`). - Available globals are the standard Web ones: `Request`, `Response`, `Headers`, `FormData`, `File`, `URL`, `URLSearchParams`, `TextEncoder`, `TextDecoder`, `atob`, `btoa`, `crypto` (`randomUUID`, `getRandomValues`, and `subtle`: SHA-1, SHA-256, SHA-384 and SHA-512 digest and HMAC; AES-GCM, AES-CBC, AES-CTR and AES-KW; RSASSA-PKCS1-v1_5, RSA-PSS and RSA-OAEP; ECDSA/ECDH on P-256, P-384 and P-521; Ed25519, X25519, HKDF and PBKDF2), `JSON` and `console`. `subtle` provides `digest`, `generateKey`, `importKey`, `exportKey`, `encrypt`, `decrypt`, `sign`, `verify`, `deriveBits`, `deriveKey`, `wrapKey` and `unwrapKey`. RSA-PSS `saltLength: 0` throws `NotSupportedError` for both signing and verification; nonzero lengths are exact. There is no `setTimeout`, `structuredClone` or background work (`ctx.waitUntil`), and calling `fetch()` for another site throws `V1_FEATURE_LATER`. - Web Crypto uses native `CryptoKey` metadata and standard `DOMException` names; wrong argument types throw `TypeError`. Exporting a non-extractable key or using a key without the required usage throws `InvalidAccessError`. Asymmetric generation returns `{publicKey, privateKey}`; public keys are extractable even when the private key is not. Key formats are algorithm-specific: AES/HMAC use `raw` or `jwk`; asymmetric public keys use `spki` or `jwk` (EC/Ed25519/X25519 also allow `raw`), and private keys use `pkcs8` or `jwk`. HKDF/PBKDF2 accept only non-extractable `raw` keys. ECDSA signatures are fixed-width IEEE P1363 `r || s`, as used by ES256 JWTs, not ASN.1 DER. - AES key sizes are 128, 192 or 256 bits. AES-GCM follows Node's 12–128-byte IV range and supports `additionalData` and `tagLength` 96, 104, 112, 120 or 128; 32/64-bit tags throw `NotSupportedError`. Use a fresh unpredictable IV for every GCM encryption with a key. Prefer GCM for stored credentials: CBC and CTR do not authenticate ciphertext. Authentication failures, invalid CBC padding and AES-KW integrity failures throw `OperationError`. - RSA generation accepts `modulusLength` 1024–4096 and `publicExponent` 65537 (`new Uint8Array([1, 0, 1])`); larger imported moduli are allowed up to 8192 bits. Out-of-bound moduli or a different generation exponent throw `OperationError`. RSA hashes are SHA-1/256/384/512; OAEP accepts `label` and PSS requires `saltLength`. PBKDF2 requires 1–100,000 iterations; zero throws `OperationError`, and more than 100,000 throws `NotSupportedError`. PBKDF2 output is limited to 4096 bits, and generated or derived HMAC keys to 65,536 bits; exceeding either throws `OperationError`. Imported HMAC keys are bounded by the supplied key data. HKDF output is limited to 255 hash blocks. Ed448 and X448 throw `NotSupportedError`. Use SHA-1 only where required for interoperability, such as Twilio HMAC webhook verification; prefer SHA-256 or stronger for new designs. - Each request runs in a fresh engine. Module-level variables do not survive between requests; keep state in `env.holm` storage. - If the handler throws, the visitor gets a 500 with a short code and the details go to the logs: `holm @ app logs ` (`console.log` output appears there too). ### HTML form without JavaScript `web/index.html` can submit an ordinary form; no browser script is needed: ```html
    ``` This `api/main.js` reads the form and answers 303 back to the page (the browser follows with GET): ```js export default { async fetch(request, env) { if (request.method === "POST" && new URL(request.url).pathname === "/api/notes") { const form = await request.formData(); const text = String(form.get("text") ?? "").trim().slice(0, 280); if (!text) return new Response("Text is required.", { status: 400 }); env.holm.app.ds.insert("notes", { text }); return Response.redirect(new URL("/", request.url), 303); } return env.holm.web.fetch(request); }, }; ``` ### One page per document id Serve a private HTML template yourself for distinct URLs such as `/notes/`. This `api/main.js` uses an existing document's `text` as the title and escapes both text and URLs: ```js const escapeHTML = (value) => String(value).replace(/[&<>"']/g, (ch) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'", }[ch])); export default { fetch(request, env) { const url = new URL(request.url); const match = /^\/notes\/([^/]+)$/.exec(url.pathname); if (match) { const note = env.holm.app.ds.findOne("notes", decodeURIComponent(match[1])); if (!note) return new Response("Not found.", { status: 404 }); const html = env.holm.private.read("note.html") .replaceAll("{{title}}", () => escapeHTML(note.text)) // a function keeps "$&" literal .replaceAll("{{url}}", () => escapeHTML(url.origin + url.pathname)); return new Response(html, { headers: { "Content-Type": "text/html; charset=utf-8" } }); } return env.holm.web.fetch(request); }, }; ``` `api/note.html` is private, not a public page. `private.read` returns its contents as a string: ```html {{title}}

    {{title}}

    ``` The handler owns these head tags; add description and `og:image` for a complete social card. If instead you pass a rewritten Request to `env.holm.web.fetch`, its generated canonical and `og:url` use the rewritten template path, not the original document URL. ## 12. Runtime namespace reference All storage calls are synchronous: call them directly, without `await`. V1 shared storage is `env.holm.app.kv`, `env.holm.app.ds` and `env.holm.app.s3`. From 0.249.0, KV/DS deletes and overwrites erase obsolete bytes from the active SQLite DB after a successful WAL checkpoint; hourly maintenance retries busy checkpoints. Pre-migration snapshots keep captured rows until a later migration and operator backups keep what they captured. S3 deletes collect only unreferenced blobs: inline bytes follow the DB rule, local files are unlinked not wiped, and external retention is provider-controlled. This is not retroactive scrubbing or physical-media erasure. **Shared data** (visible to every request of the app): ```js const { kv, ds, s3 } = env.holm.app; kv.set("visits", 0); kv.get("visits"); kv.increment("visits"); kv.delete("visits"); kv.list("prefix:"); kv.set("cache:x", value, 60_000); // optional TTL in ms const id = ds.insert("notes", { text: "hi", done: false }); // returns the id ds.find("notes", { done: false }, { limit: 50, order: "desc" }); // array, newest first ds.findOne("notes", id); // document, or null for an unknown id ds.update("notes", { id }, { $set: { done: true }, $inc: { edits: 1 } }); ds.delete("notes", { id }); ds.count("notes", { done: false }); ds.search("notes", "hello", { limit: 20 }); // { results, total } ds.transaction((tx) => { const id = tx.insert("orders", {}); tx.update("orders", { id }, { $set: { ok: true } }); }); s3.put("uploads/a.png", await request.arrayBuffer(), "image/png"); s3.get("uploads/a.png"); // { data: ArrayBuffer, mime, size, hash } or null s3.list("uploads/"); s3.delete("uploads/a.png"); ``` - DS queries: equality plus `$eq $ne $gt $lt $gte $lte $in $nin $contains $or`; updates: `$set $unset $inc`. Documents come back with `id`, `_createdAt`, `_updatedAt` and `_rev`. `_createdAt` and `_updatedAt` are Unix milliseconds, stamped at second precision (not unique sequence numbers). Always pass a `limit`. Page with `{ page: true, limit: 50 }`, which returns `{ rows, cursor }`; pass that cursor back with the same query and order. - `order` chooses creation-time direction: `"desc"` is newest first, `"asc"` oldest first. Equal creation times use insertion order in the same direction, including cursor pages. This is not update order. To sort another field, fetch a bounded set and sort it in JavaScript. - Never `await` inside a `ds.transaction` callback. - From 0.268.0 a `ds.transaction` callback may only read and write through `tx`. Other effects inside it (`kv`/`s3` writes, `net.fetch`, email, SMS, AI, admin) fail at once with `409/DS_TRANSACTION_OPERATION_UNSUPPORTED`; run them before or after the callback. Reads such as `kv.get` still work. At its 2 s cap the transaction rolls back and frees the write lock. - Only `id` is a reserved document field. Before 0.257.0 a top-level `session` field matched queries by its inserted value only, so a `session` set by `update` was never found. - From 0.249.0, `holm.json` `"ds":{"search":{"exclude":["ledger","events"]}}` skips FTS maintenance for exact shared collection names (no globs; up to 256, deduplicated; names match `[A-Za-z0-9_][A-Za-z0-9_.-]{0,127}`). Search on an excluded collection throws an error naming `ds.search.exclude`. Redeploying without an exclusion backfills old documents in bounded background batches; search throws `rebuilding` until ready, never partial/stale results. - Nothing in storage is public. To serve stored files, return them from `fetch`. **Per-member data**: `env.holm.app.member.kv`, `.member.ds` and `.member.s3` have the same methods but are private to the signed-in visitor. They throw when nobody is signed in, so check `env.holm.auth.isLoggedIn()` first. **Sign-in** (read-only: who is signed in and their role): ```js const { auth } = env.holm; auth.isLoggedIn(); // boolean auth.getMember(); // { id, name, email, picture, ... } or null auth.getLoginURL("/after"); // link to the node's sign-in page, returning to /after auth.getLogoutURL(); // where the sign-out POST goes; a GET does not sign out auth.isAppOwner(); auth.isAppAdmin(); auth.hasRole("editor"); auth.isNodeOwner(); auth.isNodeAdmin(); // the node's own owner and admins // From 0.245.0, isAppAdmin() is also true for the node's owner and admins. auth.requireLogin(); // signed-out visitor: 307 to the sign-in page, back to / ``` - Visitors sign in at `/auth/login` on your app's own domain; the node provides the page and the sign-in methods the node owner enabled. Link to `auth.getLoginURL(...)`. - Sign-out is a `POST` to `auth.getLogoutURL()`, so make it a button, never a link: a `GET` answers 405 and the visitor stays signed in. It takes effect at once, with no confirmation page. Post with `Accept: application/json` (Holm answers 200), then reload: `await fetch(me.logoutURL, { method: "POST", headers: { accept: "application/json" } }); location.reload();` - The session cookie belongs to your domain only. Call your API from your own pages with same-origin `fetch`, and browsers send the cookie automatically. Cross-site `POST`s from signed-in visitors are rejected. - `getMember().email` is only ever a verified address: the member proved it with a one-time code, a sign-in provider reported it verified, or the node's operator bound it by hand. A member without one has no `email`. It is safe to match a person's records on it. - The `auth` namespace cannot create members or change roles. Privileged `admin` calls require caller authority and app grants. **Other:** `env.holm.private.readJSON("data.json")` (also `read`, `exists`, `list`) reads files you deployed under `api/` (paths are relative to `api/`, any file type is allowed, and `read` returns a string). `env.holm.log.info/warn/error` write to the app log. `env.holm.app.id` is the app id. `env.holm.web.fetch(request)` serves the app's own static files. The following namespaces are subject to per-app policy, permissions, limits, request budgets and errors. Availability is not an authority grant. **Secrets:** `get(name)` reads only the current app's runtime secret. A missing secret, including one belonging to another app, returns `undefined`. The node owner sets it with `holm secret runtime set --app API_TOKEN `. `ref(name)` creates an opaque secret reference without disclosing the value. ```js const token = env.holm.secrets.get("API_TOKEN"); ``` **Environment:** `get(name, fallback)` and `has(name)` use the current app's saved environment values, not the node process's environment. A missing value returns the fallback unchanged, or `undefined` without one. ```js const mode = env.holm.env.get("MODE", "default"); ``` **Outbound HTTP:** `net.fetch(url, options?)` is synchronous. Its response has `status`, `ok`, `headers`, `text()` and `json()`. It uses the node's egress policy, auth, call/body/concurrency limits and timeouts. Loopback is refused with `NET_BLOCKED`; do not widen production egress to permit it. ```js const response = env.holm.net.fetch("https://api.example.com/probe"); const data = response.json(); ``` **Email:** `send({ to, subject, body?, html? })` is synchronous and returns a structured result, not a promise. App send policy, sender identity and the configured delivery provider govern it. Callers cannot choose `from`, provider or credentials. Policy is checked on every send. The first enabled send claims a durable canonical sender only when absent, using the node's verified sender domain; no valid domain returns `EMAIL_SEND_IDENTITY_NOT_READY`. Existing senders survive app renames. Deploying an app neither claims nor enables its sender. Sending is off for each app until an operator runs `holm settings set settings.apps..policy.email.app_send true`. The From name is the app's `brand.name`, else its label, fixed when the sender is claimed: set `settings.apps..brand.name` before the first send, and check or correct the sender later with `holm email identity show --app ` and `holm email identity set --app --local --name `. Delivery uses the configured provider's credentials and the app-attributed egress proxy, never an app-selected sender or direct HTTP client. Results are native JS objects: success includes `ok`, `status`, `from`, `provider`, `providerMessageId`, `messageId` and `deliveryId`; failures include `ok`, `code`, `message` and `retryable`. With sending disabled, the result has `ok: false`, `code: "EMAIL_SEND_DISABLED"`, a message and `retryable: false`. Read operations refuse `email.send` with `READ_EFFECT_DENIED` before identity or network work. Write/destructive operations may send under current app policy; a matching keyed operation replay returns the stored result without sending again. Unkeyed/raw fetch sends have no platform email replay guarantee. ```js const email = env.holm.email.send({ to: "reader@example.com", subject: "Hello", body: "Hello" }); ``` **SMS:** `env.holm.sms.send({to,body})` or `env.holm.sms.send({to,template:{id,vars}})` is synchronous. One E.164 recipient, nonempty valid text and a configured driver are required; the app cannot select provider, sender or credentials. Success is `{ok:true,status:"queued",deliveryId,encoding,segments,warnings}`; refusal is `{ok:false,code,message,retryable}`. Results never disclose message text, variables or credentials. `queued` is durable admission, not carrier delivery; inspect `holm sms receipts --app ` for stored acceptance/delivery metadata. An exact app binding wins even when disabled, invalid or missing credentials; only absence inherits the node default. Node secrets cannot be shadowed by app secrets; app bindings use exact app secrets only. `sms.app_send` defaults true; setting `settings.apps..policy.sms.app_send false` stops app-attributed sends independently of binding removal. Read operations throw `READ_EFFECT_DENIED` before send/opt-out effects; matching keyed write replay returns the admission result. Queued text/templates/provider config retain their admitted snapshot. App off and pinned provider disable/removal are rechecked before dispatch/retry. `env.holm.sms.optout.add({to,reason})` and `env.holm.sms.optout.remove({to,consentConfirmed:true})` require a current authenticated human node admin/owner, not a caller-supplied actor. `sms.own_optout_list` defaults false; true directs future app mutations to its own list. The node OR current-app suppression union always blocks admission/dispatch/retry. CLI exact scope remains independent. Consent-confirmed removal never clears a provider's own block or revives old failed work. Holm does not automatically parse STOP replies or retract an in-flight SMS. Defaults: 10 attempts/app/minute, 100/app/day and 500/node/day; pending limits 100/app and 500/node; maximum segments is the minimum of app/node caps (default 3, range 0–3). Zero disables, never means unlimited. Intent charges retries and simulation too; failures are not refunded and recovery does not double-charge. GSM-7 estimates 160 single/153 multipart septets (extensions cost two); Unicode 70/67 UTF-16 units (non-BMP costs two). Text is never trimmed, transliterated or truncated. Estimates are not billing guarantees. Templates render locally for log, SMSGate and Twilio; native Twilio Content and provider/DLT registration are absent. Indeterminate timeout/reset/unknown outcomes are not blindly resubmitted; `delivery retry` refuses accepted/simulated/uncertain work. **AI:** `chat(options)` runs synchronously with provider selection, app/member limits, budget guard and usage records. It returns content/model/usage or throws an `AIError` with its code. Without a provider, it throws `ai.not_configured`. ```js const answer = env.holm.ai.chat({ messages: [{ role: "user", content: "ping" }] }); ``` **Images:** `thumbnail(arrayBuffer, size)` and `resize(arrayBuffer, options)` apply image validation limits and return `{ data: ArrayBuffer, width, height, size }`. ```js const pixels = Uint8Array.from(JSON.parse(new URL(request.url).searchParams.get("png"))).buffer; const image = env.holm.image.thumbnail(pixels, 1); ``` **Admin:** member, role, audit and cross-member storage operations require caller-role, app capability and audit checks. Fresh v1 installs cannot request new capabilities yet. A signed-in non-admin caller is refused. ```js const members = env.holm.admin.members.list(); // refused for a non-admin ``` **Node:** `node.analytics` requires an admin/owner caller and an existing app capability; node-wide reads also require app policy. Without that grant, a fresh v1 app is refused even for an admin. ```js const analytics = env.holm.node.analytics.query({ window: "24h" }); ``` Not available in v1 yet (`V1_FEATURE_LATER`): `agent`, `worker`, `task`, `app.queue`, `app.events`, `realtime.grant`, `realtime.revoke` and `ai.job`. Global `fetch()` refuses outbound HTTP; use `env.holm.net.fetch` instead. ### Realtime (server + browser) The node owns `/_ws`. An app's fetch handler can broadcast to clients subscribed to the same channel; this `api/main.js` sends an update on a POST: ```js export default { async fetch(request, env) { const url = new URL(request.url); if (request.method === "POST" && url.pathname === "/api/send") { env.holm.realtime.broadcast("updates", { text: "Hello from Holm" }); return Response.json({ subscribers: env.holm.realtime.count("updates") }); } return env.holm.web.fetch(request); }, }; ``` Add this browser client to `web/index.html`; wait for "Subscribed", then click the button: ```html Waiting for messages ``` - On HTTPS this opens `wss:///_ws` (local HTTP uses `ws://`). Frames can contain multiple newline-separated JSON messages; respond to the application `ping` with `pong`. - Reconnect the WebSocket when it closes, and subscribe again on the new connection. - Untyped names like `updates` are public; do **not** prefix them with `public:`. `private:` and `presence:` need a signed-in member with app read access and must pass the node's realtime policy. - Native operations: a `write` or `destructive` operation may call all of `env.holm.realtime`; a `read` operation may call only `count` and `subscribers`, and the rest throw `READ_EFFECT_DENIED`. ## 13. Authentication and authorization ### 13.1 Browser sign-in Use the node-provided `/auth/login` page on the app's own domain and the login and logout URLs from `env.holm.auth`. The app reads identity and roles; it does not create members or change roles through that namespace. Same-origin browser fetch sends the host's session cookie automatically. Cross-site signed-in POSTs are rejected. Keep the app's API and pages on the same origin. Check `isLoggedIn()` before member-private storage. ### 13.2 Configure OAuth as an operator The binary's provider command supports Google, GitHub, Discord and Microsoft: ```bash holm auth provider google \ --client-id '' \ --client-secret '' \ --enable holm auth providers ``` Use `--host ` for one host's provider credentials. Consult `holm auth provider --help` before changing them. Apps use the sign-in methods the node owner has enabled; do not invent an app-specific login flow. ### 13.3 Operator PATs and scoped API keys ```bash holm member create --kind service --role admin --persona '{"name":"Operator"}' holm member token holm member info ``` The new PAT is printed once. Store it securely. Member lifecycle commands change current access: ```bash holm member disable holm member suspend holm member activate ``` For bounded deployment work, create a scoped API key on the server: ```bash holm server create-key \ --name deploy-site \ --scopes 'apps:app.example.com:deploy' holm server list-keys holm server delete-key --id ``` Use a member PAT with the required role for binary upgrades. A scoped key is not an owner/admin member credential. ## 14. Networking and routing ### 14.1 Listener and DNS Local examples use `holm.localhost` and port 4698. A production service install already listens on every interface. To change an existing node's listener, configure it deliberately: ```bash holm server set-config --bind 0.0.0.0 holm restart ``` Point the exact admin and app hostnames at the node with appropriate A/AAAA records. The v1 guide requires the app's DNS to point to the node. Wildcard DNS alone does not bind an app: use an exact host route. ### 14.2 HTTPS and certificates The Linux service installer accepts `--https` and an ACME contact `--email`. Make DNS and the public network path work before relying on certificate issuance. Inspect a domain without printing key material: ```bash holm cert list holm cert inspect app.example.com ``` After diagnosing a known-bad domain entry, clearing it is explicit and potentially disruptive: ```bash holm cert clear app.example.com --force ``` ### 14.3 Exact host routes ```bash holm host list --limit 1000 holm host add app.example.com --app holm host update app.example.com --app --force holm host remove app.example.com --force holm host unknown ``` `host add` creates a route; `host update` retargets an existing one. The unknown-host sink rejects unrecognized hosts rather than turning them into app routes. Its branded or custom 404 page is described in section 6.9. Inspect the intended target before replacing a route or app. ### 14.4 Realtime endpoint The node owns `/_ws`. Use `wss:///_ws` on HTTPS and `ws://` on local HTTP. Section 12 contains the browser protocol and channel rules. ## 15. Programmatic clients and HTTP API Static files, app `fetch` bodies and operation results have a 30-second deadline per response-write chunk (up to 32 KiB), refreshed before the next chunk. This is not a whole-response timeout: quiet handlers can wait before writing, and moving clients can finish long downloads. A chunk that remains blocked for 30 seconds can fail even with a very slow trickle. Writers without deadline support keep working as before. Request-body draining and existing header, idle-connection, connection-count and body-size limits are unchanged; no body-idle timeout is added. ### 15.1 Browser clients The complete v1 example uses same-origin browser `fetch` and a standard `Response` from `api/main.js`. No SDK is required. `/api/me` is an app-owned route: the guestbook returns `member`, `loginURL` and `logoutURL` there. Use the response shapes your handler defines. Do not assume a client library creates routes, bypasses authorization or supplies additional runtime APIs. The optional `@holmhq/sdk` browser helper comes from GitHub, not the npm registry. The latest SDK release is the newest tag at https://github.com/holmhq/sdk/tags, independently of the Holm binary version. For raw/BFBB apps, import the browser entry from GitHub through jsDelivr: ```js import { createWebApp } from 'https://cdn.jsdelivr.net/gh/holmhq/sdk@latest/dist/web/index.js' const holm = createWebApp() // Use the client with routes your app actually provides. // Dispose it when the owning UI lifecycle ends. ``` `@latest` resolves to the newest GitHub tag, not the default branch or npm. The relative ESM imports resolve on jsDelivr, which the hosted-app script CSP allows. Use an actual `@vX.Y.Z` tag in place of `@latest` for reproducible builds. Vendoring the complete `dist/` tree from an immutable GitHub tag or commit still works without a runtime CDN dependency; verify `dist/manifest.json` and import `/vendor/holm-sdk/holm-web.js` locally. See https://github.com/holmhq/sdk/blob/HEAD/docs/vendoring.md for the steps. For bundled apps (including Vite), install from GitHub: ```bash npm install "github:holmhq/sdk#semver:*" ``` ```js import { createWebApp } from '@holmhq/sdk/web' const holm = createWebApp() ``` `#semver:*` selects the newest GitHub release tag. A lockfile keeps the resolved commit; rerun the install command when you want an update. For reproducible builds, install `github:holmhq/sdk#vX.Y.Z` with an actual release tag and retain the lockfile. npm is only the installer here, not the source. ### 15.2 Operator API discovery The released binary includes a compiled route registry: ```bash holm api routes --format json ``` Use it to discover the selected release's operator routes instead of guessing URLs, request bodies or authorization requirements. This reference does not copy an unverified HTTP route or SDK method inventory. ### 15.3 Deploy from automation Use the same reviewed directory path as an interactive deploy: ```bash holm app validate ./my-app --spec-v1 holm @ app deploy ./my-app --spec-v1 --host app.example.com ``` Build locally if needed. Keep the credential limited to the intended host or app. Automation does not authorize replacing a domain the human did not approve for the task. ## 16. Persistence, backup and portability ### 16.1 Durable state and files App storage survives normal redeploy. Deployed files are replaced as one set. Each request has a fresh JavaScript engine; module globals are not durable state. WebSocket connections are live connections, not a backup of app data. Do not use timestamps as unique sequence numbers. The document store provides IDs, revisions and cursor pages; section 12 states its exact timestamp and ordering behavior. ### 16.2 Object placement ```bash holm db objects status --json ``` Treat a one-file copy as complete only when `canonical_complete: true`. Current Object commands support local detachment, materialization, provider mirroring, verified activation and inactive-provider retirement. All placement-changing operations below require a stopped node: ```bash holm stop --force holm db objects detach-local --json holm db objects materialize --output ./standalone-data.db --json ``` For a configured provider: ```bash holm db objects mirror --provider --json holm db objects activate --provider --json ``` A mirror is not an activation or a replacement for a backup. Retirement removes an eligible inactive owned provider namespace and is destructive: ```bash holm db objects retire --provider --json ``` These are Object operations, not a different node database backend. Do not infer whole-node portability from a provider mirror alone. ### 16.3 Backup Select the real database, inspect completeness, then stop the server. Stop a manual foreground runtime with `Ctrl-C`; the example uses service shutdown. For a complete source: ```bash holm db objects status --db ./data.db --json holm stop --force holm db artifact create --source ./data.db --out ./backup-artifacts holm db artifact verify ./backup-artifacts/ ``` Artifacts contain a payload and manifest. Never copy a live WAL database as if its main file alone were complete. Materialize detached bytes into a new standalone database before relying on a single-file backup. From 0.249.0, peer backup can snapshot a running node; it does not require remote service shutdown. A peer saved with an owner/admin credential or a `system:*` key backs up directly; an unattended backup job can use a key that can only back up: ```bash holm @prod db objects status --json holm @prod db artifact create --out ./backup-artifacts --json holm db artifact verify ./backup-artifacts/ holm @prod db artifact verify --json # Backup-only key for a job, minted on the node: holm server create-key --name backup --scopes system:db:backup holm peer add archive --url https://your-node.example --token ``` Whoever can mint a backup key can back up: these peer routes take an owner/admin credential or a `system:*` master key, or a key with the literal `system:db:backup` scope (a narrower wildcard such as `system:db:*` does not match); anything else returns 403 naming the scope. A live `VACUUM INTO` snapshot uses the same completeness refusal as local create. From 0.250.1, the node builds it in the background and every request answers within seconds, so a reverse proxy's first-byte timeout (Cloudflare: 100 s) cannot cut a large backup: the CLI starts the snapshot, polls it, downloads its files (a dropped download resumes with HTTP Range) and deletes the node's copy. The caller fsyncs private temporary files, checks payload size/SHA-256 and SQLite integrity, then atomically publishes the artifact directory. Cancellation or transfer/verification failure deletes the staging files. SIGKILL/power loss cannot run caller cleanup; hidden staging is not a completed artifact. The node permits one remote backup at a time (409 when busy), checks database + WAL size plus a 1 MiB free-disk guard first, and removes its copy when the CLI is done, after an hour without a request, or at startup. Peer `verify` downloads, verifies, and discards a **fresh** snapshot; use local `verify ` for an already downloaded artifact. Peer `--out` is a caller-local directory; peer `--source`/`--db` paths are refused. Restore and Object placement mutations remain local-only and attended. ### 16.4 Restore verification Restore into a fresh alternate directory first: ```bash holm db artifact restore-verify \ --out ./restored-db \ --proof ./restore-proof.json \ ./backup-artifacts/ ``` The command opens the alternate copy read-only. It does not activate a replacement. Preserve the existing database, stop the service outside this command, select a compatible binary, inspect the restored copy, and choose any active replacement explicitly. Check ownership and permissions before starting it. ### 16.5 Moving a node Move a complete cartridge over a protected channel and select it explicitly at the destination. Verify its completeness and the destination binary before starting it. Do not treat downloading an app's files as exporting its storage. ### 16.6 Retention ```bash holm db retention status --json holm db retention run --json ``` `run` is a preview by default. Applying retention requires explicit local confirmation and verified backup evidence: ```bash holm db retention run \ --apply \ --force \ --backup-marker verified: \ --json ``` Remote retention is status and dry-run only. Inspect before applying. ## 17. Upgrading Holm Select the instance and make a verified complete backup before replacing a binary. Check the available upgrade first: ```bash holm --version holm server status holm upgrade --check ``` Review the selected binary's embedded licence with `holm license show` and inspect instance acceptance with `holm license status`. If a target identifies new terms, deliberately accept the reviewed target ID and digest in that instance; never auto-accept fetched terms. ```bash holm license accept \ --file ./reviewed-license.md \ --id \ --digest \ --db ./data.db ``` ```bash holm upgrade holm --version holm status holm logs -n 50 ``` For a peer with an owner/admin member credential: ```bash holm @prod status holm @prod upgrade --check holm @prod upgrade holm @prod status ``` Do not bypass upgrade refusals or patch a failed database. Preserve the existing binary and database for diagnosis. Restore into an alternate path with a compatible binary before deliberately replacing active state. ## 18. Production deployment 1. Configure DNS for the exact admin and app hosts, and prepare the firewall. 2. Install the official binary and review its licence. 3. Install the Linux service with the domain, ACME email and HTTPS enabled; it listens on every interface. An HTTP install prints HTTP addresses. 4. Securely save the generated admin password and one-time CLI key. 5. Run the printed `holm peer add` command on the operator machine, then verify peer status and the intended public path. 6. Create a least-privilege deployment credential. 7. Test the app, a complete backup and restore verification. For app updates: ```bash holm app validate ./dist --spec-v1 holm @prod app deploy ./dist --spec-v1 --host app.example.com --force ``` Build locally into the v1 structure before validation. `--force` is for an app/domain you are authorized to replace. Several hosts targeting the same app all see its new files. If a reverse proxy is present, restrict origin access and configure only the actual trusted proxies. Preserve the intended host and scheme, and block local-only routes at the public proxy. Verify the app in a browser, not just with a local syntax check. ## 19. Security and trust model ### 19.1 App boundaries Only `web/` is public in app mode. `holm.json` and `api/` are never served as files. In plain mode the retained site folder is public after filtering. A `test/` directory inside `web/` is still public. Review every public file. Never deploy credentials, private documentation, repository history or source maps. Read private templates through `env.holm.private` relative to `api/`. ### 19.2 Caller and provider authority Sign-in is host-bound. Keep browser requests same-origin. Require sign-in before member storage and enforce app roles before sensitive operations. Admin/node namespace availability is not an authority grant. Outbound HTTP uses node policy, not unrestricted global `fetch`. AI and email use configured providers; do not assume their data stays on the node. Credentials belong in the narrow app secret store, not in source or logs. ### 19.3 Operator safety Use scoped keys for bounded operations and member credentials only where required. Never patch live SQLite, including through raw SQL. A missing management operation needs a supported CLI/API solution, not a database hack. ### 19.4 Scan shield and disclosure ```bash holm shield status holm shield rules --scope all --enabled all holm shield events holm shield add --path-prefix /.git/ --action 404 --label git-probes holm shield disable ``` Custom scan-shield rules are soft-disabled. Event output is bounded; it does not support `--since`. The shield is not a substitute for network controls. Publish an operator disclosure policy: ```bash holm security-txt set \ --contact mailto:security@example.com \ --policy https://example.com/security holm security-txt show ``` Use `--host ` or `--app ` for scoped overrides. Do not put credentials in the disclosure document. ## 20. Performance and resource behavior Choose workloads that fit one process and SQLite's single durable writer. Benchmark the actual app rather than promising a fixed server size or RPS. Keep storage queries bounded and app work within its request budget. Each request gets a fresh engine. Do not use module globals as durable state, and do not assume a Node.js event loop. Reconnect realtime clients when the connection closes. Back off on overload rather than tight-looping retries. Inspect resource and storage use: ```bash holm system stats --format json holm db inspect --json holm db usage --group app --limit 20 --json holm db candidates --class orphan --limit 20 --json ``` ## 21. CLI reference ### 21.1 General rules ```text holm [options] holm @ [options] ``` Global output flags are `--verbose` and `--format markdown|json`. `--version` reports the binary. Command-family `--help` is the safe starting point; some leaves reject it or execute instead of treating it as help. Use the family's documented syntax in those cases. Many lists support `--offset` and `--limit`; do not assume one limit for all commands. Some surfaces use `--json` instead of the global output flag. Inspect the documented format before scripting a result. ### 21.2 Lifecycle and server ```text server init initialize the selected instance server start foreground server; --db, --bind, --port, --domain server status read-only database/configuration inspection server set-config persist listener, environment, CORS and debug fields server set-credentials reset node administration credentials server create-key create a scoped key with --name and --scopes server list-keys list key metadata server delete-key revoke a key with --id service install local Linux systemd installation start auto, service or manual runtime mode stop --force explicit service shutdown restart restart the service status runtime and health report logs -n service logs upgrade upgrade; --check for preflight, --db for instance license show embedded terms license status instance acknowledgement license accept explicit reviewed ID and digest ``` Service installation needs direct host access. Remote upgrade needs owner/admin member authority. Do not probe direct service start/stop leaves with `--help`; use the family help. ### 21.3 Apps and routing ```text app validate --spec-v1 local v1 folder check, not execution app deploy --spec-v1 directory deploy; --host and authorized --force app install ///[@ref] [--host ] ready v1 folder from git; checksum pins, no node build app identity symbol [dir] holm.symbol.svg from a simple filled SVG icon app identity generate [dir] icons, manifest and social card from holm.symbol.svg app list list apps on the selected peer app info inspect app details app files inspect deployed files app request GET / exercise an app route app logs runtime logs; --since, --limit, --member, --json app analytics purge / app logs purge filtered dry run; --apply for audited deletion app kv list|get read-only app KV inspection app ds collections|find|count read-only app DS inspection host list exact FQDN routes; --offset and --limit host add --app create a route host update --app --force retarget a route host remove --force remove a route host unknown inspect the unknown-host sink ``` No node-side build runs. Local validation does not upload or execute the app. Use the ID printed by deploy. For app authoring, keep to the APIs in sections 9–13 rather than inferring capabilities from other binary command families. ### 21.4 Identity and peers ```text auth provider configure OAuth; --host for a host override auth providers list configured providers member create create a person/service member and first PAT member token issue another PAT member list|info inspect members member disable|suspend|activate change lifecycle state peer add --url --token save a peer credential peer list safe peer metadata; --format json peer check liveness and authorization diagnostics peer update-token --token rotate the stored credential peer default choose the default peer peer remove remove a peer ``` ### 21.5 Data and configuration ```text settings get|set|reset|list|schema typed node/app settings; --json policy get|set|reset|list|schema gates and limits; --app and --json db inspect read-only composition; --db, --limit, --json db usage usage tree; --group table|app|family db candidates reclaim evidence; --class and --limit db retention status|run governed retention; run previews by default db artifact create|verify|restore-verify local/peer backup; alternate restore stays local @peer db artifact create --out @peer db artifact verify verify and discard a fresh streamed snapshot @peer db objects status Object completeness (owner/admin, system:* or system:db:backup) db objects status Object completeness and placement db objects detach-local|materialize|mirror|activate|retire offline Object operations system stats runtime statistics; --format json activity list|stats activity records and aggregates api routes compiled HTTP route registry; --format json ``` Remote DB support includes bounded read-only inspection, retention preview and fresh backup snapshots with caller-local downloads. Object placement changes require direct local access and a stopped node. ### 21.6 Egress and security ```text net allow egress rule; --app, --http, --timeout, --max-response net remove remove an egress rule secret set|list|remove outbound-auth values; --app and --domain secret runtime set|list|remove app-readable runtime secrets; --app shield status|rules|add|disable|events scan-shield inspection and soft-disabled custom rules cert list|inspect|clear certificate inspection and explicit --force recovery security-txt set|show|disable disclosure policy; --host or --app overrides ``` These operator commands do not bypass the v1 request budgets, app policy or caller checks described in the runtime guide. ## 22. Common recipes ### Configure a deployment peer Use the credential created for this task, not an invented token: ```bash holm peer add prod --url https://admin.example.com --token '' holm peer check prod ``` ### Deploy a reviewed folder If you build, put the output in `web/` and bundle server dependencies into `api/main.js`. Inspect the final folder, then: ```bash holm app validate ./my-app --spec-v1 holm @prod app deploy ./my-app --spec-v1 --host app.example.com ``` No build is needed for ordinary static/browser files. Do not include unrelated source, credentials, private docs or tooling in the public tree. ### Read and write app data Use the synchronous shared KV/DS/S3 examples in section 12. For per-member data, check `env.holm.auth.isLoggedIn()` first and use `env.holm.app.member`. Serve stored files from the fetch handler, not as public deployment files. ### Receive an HTML form Use `await request.formData()` as in section 11. Return a `Response`, such as 303 back to the page after a successful write. No browser script is required. ### Store an uploaded file A form's file field is a `File`. `s3.put` takes a string or an `ArrayBuffer`, so pass the file's bytes, not the file itself: ```js const form = await request.formData(); const file = form.get("photo"); if (typeof file === "string" || !file || file.size === 0) { return new Response("Choose a photo.", { status: 400 }); } const id = env.holm.app.ds.insert("photos", { name: file.name, type: file.type, size: file.size }); env.holm.app.s3.put(`photos/${id}`, await file.arrayBuffer(), file.type || "application/octet-stream"); ``` Serve stored files from the fetch handler with `s3.get` (section 12), checking who may see them; they are not public deployment files. ### Keep uploads under the node's limits Upload handling in 0.249.0 uses two node limits. The request body limit is `settings.node.policy.runtime.request_body_max_bytes`: 1 MiB by default, and an operator raises it with `holm settings set` (for example to 33554432 for 32 MiB). Each request also has a JavaScript memory budget, 128 MiB by default and less on small nodes (see "Stay within the memory budget" below). Multipart files share the already-bounded request body without a payload copy. Reading a file with `arrayBuffer()` or `text()` charges the result before allocating it, so parsing and reading one file can fit about half the budget: roughly 60 MB at the default, so the 32 MiB body limit in this example binds first. Fields, headers, other app allocations and repeated reads reduce that headroom. Passing an immutable File to a Request or Response also shares its bytes; reading that body still makes a budgeted copy. Past the body limit the node answers 413 with `PAYLOAD_TOO_LARGE`. A form parse or uploaded-file read that exceeds the memory budget also answers 413 with a clear memory-budget message, not a server error. Part headers are separately capped at 16 KiB, 64 lines and 64 parameters before parsing; malformed or oversized headers reject with a TypeError. Check sizes in the browser first, and treat a 413 as "too large" rather than a generic failure: ```js const MAX_UPLOAD = 25_000_000; // example: under a 32 MiB body limit if (file.size > MAX_UPLOAD) return showError("That file is too large (25 MB at most)."); const response = await fetch("/api/photos", { method: "POST", body: form }); if (response.status === 413) return showError("That file is too large for this server."); ``` ### Stay within the memory budget The budget counts every JavaScript allocation a request makes, including garbage that is already unreachable, and each object costs a few hundred bytes. Work that allocates heavily but keeps little, such as parsing source text and walking the parsed tree with `Object.entries` and destructuring, can run out long before its live data would fill the budget. Prefer `for...in` loops over building entry arrays in hot loops, and bound how much input one request parses. The default is 128 MiB per request. A small node lowers it so that all of its JavaScript runtimes together use at most half of its Go memory limit; an operator reads the effective figure as `effective_max_memory_bytes` in `holm system stats --format json`. An operation that runs out answers 500 `MEMORY_LIMIT_EXCEEDED`, and the app log gets one line naming the operation, the limit and the bytes used. Retrying the same input fails the same way. ### Shrink photos before upload Phone photos are often 5–15 MB. Resize them in the browser before sending: the upload is faster, it stays under the limits above, and the server stores less. Pick the size by how the image will be shown: - avatar or icon in a list: 64 px (shown at 32 CSS px on high-density screens); - larger avatar or thumbnail: 512 px, about 20–50 KB; - shown in a page or a chat: 1600 px, about 150–200 KB; - kept for zoom or download: 2048 px (the default below), about 300 KB. Use JPEG at quality 0.82. Do not ask the canvas for WebP: some browsers, Safari among them, cannot encode it and silently return a larger PNG. iPhones can hand the page HEIC photos, which most browsers cannot display, so convert those to JPEG too. Safari can decode HEIC; where a browser cannot, the original is sent unchanged. ```js async function shrinkPhoto(file, maxSide = 2048, quality = 0.82) { const heic = /^image\/hei[cf]$/.test(file.type) || /\.hei[cf]$/i.test(file.name); if (!heic && (!/^image\/(jpeg|png|webp)$/.test(file.type) || file.size < 1_500_000)) return file; try { const bitmap = await createImageBitmap(file); const scale = Math.min(1, maxSide / Math.max(bitmap.width, bitmap.height)); const canvas = document.createElement("canvas"); canvas.width = Math.round(bitmap.width * scale); canvas.height = Math.round(bitmap.height * scale); canvas.getContext("2d").drawImage(bitmap, 0, 0, canvas.width, canvas.height); const blob = await new Promise((resolve) => canvas.toBlob(resolve, "image/jpeg", quality)); if (!blob || (!heic && blob.size >= file.size)) return file; return new File([blob], file.name.replace(/\.\w+$/, "") + ".jpg", { type: "image/jpeg" }); } catch { return file; // the browser can't decode this image: send it as it is } } form.set("photo", await shrinkPhoto(input.files[0])); ``` The canvas copy drops the photo's metadata, including its location, and `createImageBitmap` applies its orientation. Leave GIFs, videos and documents as they are. On the server, `env.holm.image.resize` (section 12) can make a smaller copy for display. ### Make an app feel finished Unless the request names a style, aim for exceptional aesthetics: a deliberate type scale, generous spacing, one accent colour (`themeColor`), crafted light and dark themes, and real empty, loading and error states. Apply the conventions below where they fit the app: - **Phones, where it fits.** Most apps are opened on a phone: lay them out from 360 px wide, with touch targets of at least 44 px, no sideways scrolling, and `` with safe-area padding. Pad for the side insets everywhere, but for the bottom inset only inside `@media (display-mode: standalone)`: in a Safari tab the bottom address bar already covers the home indicator while `env(safe-area-inset-bottom)` still reports it, so a bottom bar padded for it floats about 34 px high. A dense desktop tool may target wide screens instead, but should still open readably on a phone. - **One symbol, every icon.** Draw the mark as `holm.symbol.svg` and run `holm app identity generate` (section 9.4). It makes every icon, the web manifest and the social card, and Holm links them in each page. Do not hand-draw separate icons. - **Head tags.** Set `title`, `shortName`, `description` and `themeColor` in `holm.json`, and author a `theme-color` meta for dark mode. Holm fills in the tags you leave out (section 9.2). - **Installable.** The generated manifest makes the app installable. Register a small `web/sw.js` that keeps the app shell (the page, its CSS, scripts and fonts) in a versioned cache and serves each of them from the network first, falling back to the cache when offline or after a few seconds. A cache-first shell runs the previous deploy, under the previous page's headers, on the first launch after every deploy. Holm already serves static files with `Cache-Control: no-cache` and an ETag, so the network check is a cheap revalidation; do not give shell files a long `max-age` in `web.headers`. Never cache `/api/` answers that belong to a member. - **An account menu.** Signed out, show a "Sign in" button. Signed in, show a round avatar button (the member's `picture`) that opens a popup with the name, the Light, Dark and System theme choice, any other settings, and a "Sign out" button that POSTs (see Sign-in above). Keep settings in that popup rather than around the page. - **Analytics for admins.** Holm keeps an analytics page for every app. For people allowed to see it (the node's owner and admins, and the app's owners and admins), Holm adds `globalThis.__HOLM_SURFACES__` to the app's static pages, with the page's address in `analytics`. When it is set, show an "Analytics" item in the popup that links there. Visitors who may not see it get no link, and the page itself checks again. - **A footer.** A small footer with the Holm mark and "A Holm app" linking to https://holmhq.com, plus a source link if the app is public. In an app that never scrolls (below) it is the fixed bar at the bottom. - **Feedback.** If you have an API key for a feedback app such as https://feedback.zyt.app, create a project for this app, give it the app's icon as its logo, and choose how the form opens. The floating button suits pages that scroll like documents (sites, docs, dashboards), where it covers nothing that matters. An app with a bottom bar, tab bar or composer should hide it with `data-button="none"` on the tag and open the form from a "Feedback" item in the footer or the account popup with `HolmFeedback.open()`. - **E-mail that looks like the app.** If the app sends e-mail, set its `brand.name` before the first send so the From name reads as the app, not its host (section 12, Email), keep subjects short, and send `html` with a plain-text `body`. The page learns who is signed in from one small route: ```js if (url.pathname === "/api/me") { const { auth } = env.holm; const member = auth.getMember(); if (!member) return Response.json({ loginURL: auth.getLoginURL("/") }); return Response.json({ name: member.name, picture: member.picture, admin: auth.isAppAdmin(), logoutURL: auth.getLogoutURL(), }); } ``` ### Make it feel native An app, as opposed to a page that reads like a document, should behave like one on a phone. Its page never scrolls: a fixed header and bottom bar stay put, and only the panels between them (an inbox, a list, a reader, a drawer) scroll. That removes the rubber-band bounce and pull-to-refresh, and the bars never move: ```css html, body { height: 100%; overflow: hidden; overscroll-behavior: none; } body { position: fixed; inset: 0; margin: 0; } .app { height: 100%; display: flex; flex-direction: column; } .app > header, .app > footer { flex: none; } .app > main { flex: 1; min-height: 0; display: flex; flex-direction: column; } .panel { flex: 1; min-height: 0; overflow-y: auto; overscroll-behavior: contain; } ``` Every flex parent between `.app` and a panel needs `min-height: 0`; without it the panel grows instead of scrolling. A screen that can be taller than a small phone, such as a welcome page or a long form, is a panel too. - **Nothing shifts.** Animate only `transform` and `opacity`, never height, width, margins or position. Keep animated or changing content out of `inline` and `inline-flex` boxes: their baseline moves with their content and drags the next line with it. Reserve space before content arrives: skeletons the size of what replaces them, `font-variant-numeric: tabular-nums` (or a monospace face) for timers and counters, `width` and `height` on images, and `` for self-hosted fonts. When polling, update a list in place by key and keep its scrolling element, so new rows slide in without resetting the scroll position. - **Touch, not page.** Give inputs text of 16 px or more (iOS zooms in on smaller), set `touch-action: manipulation` on the root to drop double-tap zoom, and put `user-select: none` with `-webkit-touch-callout: none` on the app's controls, turning selection back on for messages, documents and inputs. Keep hover styles inside `@media (hover: hover)` so they do not stick after a tap, and honour `prefers-reduced-motion`. Long reading pages, such as docs, posts and landing pages, keep ordinary page scrolling. ### Allow a narrow outbound host ```bash holm net allow api.example.com --app ``` Call it through `env.holm.net.fetch`, not global `fetch`. Configure credentials with the narrow app/domain secret scope in section 6. Keep calls within the request budget. ### Send SMS from an app Set up a provider using section 6.7 first. Create `sms-demo/holm.json`: ```json {"name":"sms-demo","connectedAgents":false,"surfaces":{"web":{"renderer":"off"}}} ``` Then create `sms-demo/api/main.js` with an admin-only send operation. Real apps text only consenting recipients and never expose a public send operation. Set `APP_ID` to the ID printed by deploy. Replace 4698 with the local node port for `app request`; for a cloud app use its public HTTPS URL instead of loopback. The session commands below are **local-only test helpers for a disposable development node** (initialised with `holm server init --env development`; the default environment is production). Production callers sign in as an admin on the app's own host; keep session tokens private and use the same host for creation and the request. Cookie-authenticated writes need a matching `Origin` header too; for a cloud app, replace the sample Origin with the app's public HTTPS origin. ```javascript export const contract = { operations: { "sms.send": { summary: "Send a consented SMS", input: { type: "object", additionalProperties: false, required: ["to"], properties: { to: { type: "string", minLength: 1 }, body: { type: "string", minLength: 1 }, template: { type: "object", additionalProperties: false, required: ["id", "vars"], properties: { id: { type: "string", minLength: 1 }, vars: { type: "object", additionalProperties: { type: "string" } } } } }, oneOf: [{ required: ["body"] }, { required: ["template"] }] }, output: {}, auth: "admin", risk: "write", handler: "send" } } }; export function send(input, env) { if (input.template) { return env.holm.sms.send({ to: input.to, template: input.template }); } return env.holm.sms.send({ to: input.to, body: input.body }); } export default { fetch(request) { return new URL(request.url).pathname === "/" ? new Response("SMS demo") : new Response("Not found", { status: 404 }); } }; ``` ```bash holm app validate ./sms-demo --spec-v1 holm app deploy ./sms-demo --spec-v1 --host sms-demo.holm.localhost APP_ID='' holm sms status --app "$APP_ID" --json holm sms template set appointment --app "$APP_ID" --definition '{"text":"Hello {{customer}}, appointment at {{time}}.","vars":{"customer":{"max_length":30},"time":{"max_length":20}},"drivers":{}}' holm test create-user --email sms-operator@test.local --name 'SMS Operator' --role admin SESSION=$(holm test create-session --address sms-operator@test.local --host sms-demo.holm.localhost) holm app request http://127.0.0.1:4698 POST /api/sms.send --host sms-demo.holm.localhost --session "$SESSION" --header 'Origin: http://sms-demo.holm.localhost' --json-body '{"to":"+15555550123","body":"My SMS setup test"}' holm app request http://127.0.0.1:4698 POST /api/sms.send --host sms-demo.holm.localhost --session "$SESSION" --header 'Origin: http://sms-demo.holm.localhost' --json-body '{"to":"+15555550123","template":{"id":"appointment","vars":{"customer":"Alex","time":"10:30"}}}' holm sms receipts --app "$APP_ID" --json ``` Template variables must be strings with exactly the declared keys; names are case-sensitive. Constant templates use `vars:{}`. Only `{{name}}` substitution is supported, with no expression or recursive evaluation. At most 200 templates per exact scope, 8 KiB/definition, 16 variables and 256 UTF-16 units per declared variable. App definitions win; removing one reveals a node definition for future sends only. Queued work never re-renders after an edit/removal. Registration is with Holm only. The app inherits the node default unless it has its own binding. To set an app-owned cloud binding, store its credential in that exact scope (no node-secret fallback): ```bash holm secret runtime set SMS_GATE '{"username":"","password":""}' --app "$APP_ID" holm settings set "settings.apps.${APP_ID}.providers.sms.send.default" '{"driver":"smsgate","config":{"base_url":"https://api.sms-gate.app/3rdparty/v1/messages","auth":"basic","secret_ref":"SMS_GATE"}}' holm sms status --app "$APP_ID" --json holm settings reset "settings.apps.${APP_ID}.providers.sms.send.default" ``` An app binding authorizes only its driver calls, never app fetch or another app's binding. Inspect STOP lists and use the per-app off switch independently of providers: ```bash holm sms optout add --node --to '+15555550123' --reason STOP holm sms optout list --app "$APP_ID" --limit 20 holm sms optout remove --node --to '+15555550123' --consent-confirmed holm settings set "settings.apps.${APP_ID}.policy.sms.app_send" false holm sms status --app "$APP_ID" --json holm settings set "settings.apps.${APP_ID}.policy.sms.app_send" true ``` Removal means renewed consent, not a replay instruction or provider unblock. Set `settings.apps..policy.sms.own_optout_list true` only to direct future privileged app mutations to its own list; the suppression union remains mandatory. ### Inspect storage growth ```bash holm db inspect --json holm db usage --group app --limit 20 --json holm db candidates --class duplicate --limit 20 --json ``` Candidate evidence is not permission to delete. Follow the verified backup and retention-preview path in section 16. ## 23. Troubleshooting ### `holm: command not found` Install the CLI and add its directory to `PATH`, then check the version: ```bash export PATH="$HOME/.local/bin:$PATH" holm --version ``` ### No peer or wrong destination ```bash holm peer list --format json holm peer check ``` Add the exact admin URL and supplied credential. Keep `@` explicit. A healthy peer is not proof of admin authority. Rotate a bad credential with `peer update-token`; do not edit SQLite. ### The node does not start ```bash holm server status holm logs -n 100 ``` Confirm the database selection, initialization, port and existing runtime. Use manual mode to see foreground errors, without starting a second server on the same instance. Preserve a failed database rather than patching it. ### An app domain does not resolve ```bash holm host list --limit 1000 holm @ app info ``` Its DNS must point to the node and its exact host route must target the app. An app name in `holm.json` does not create DNS. ### Static routes return 404 Check filenames and case under `web/`. `/about` comes from `about.html`; `/docs/` comes from `docs/index.html`. For SPA deep links, use `"web": { "spa": true }` in `holm.json`. Missing assets still return 404. ### Server code does not answer Check that `api/main.js` exports `default { fetch(request, env) }` and returns a `Response`. Use `env.holm.web.fetch(request)` for unmatched pages. `V1_ENTRY_INVALID`, `V1_RESPONSE_INVALID` and `V1_FETCH_FAILED` identify entry shape, response shape and handler failures. ```bash holm @ app files holm @ app logs ``` ### Private file or module is missing Deploy it under `api/`; private read paths are relative to that folder. ES-module imports must be relative and their target files must be deployed. Bundle package dependencies locally. Never put a private file into `web/` to make a server read work. ### Outbound request returns `NET_BLOCKED` Use the policy-controlled `env.holm.net.fetch` namespace. Allow only the intended hostname with `net allow --app `. Loopback remains blocked; do not weaken egress protections to hide a missing rule. For call/body/concurrency limit refusals, keep work inside the request budget and avoid tight-loop retries. ### Member storage requires login Check `env.holm.auth.isLoggedIn()` first. Sign in on the app's own domain and make same-origin browser requests. The auth namespace reads identity; it does not create members or grant roles. ### A static resource is blocked by CSP Inspect the response header and browser console. Use a deliberate static policy via `web.headers`; handler-returned HTML needs its own headers. ### AI or email refuses the call A provider and app policy must permit the operation. AI without a configured provider throws `ai.not_configured`. Disabled email returns `EMAIL_SEND_DISABLED`: an operator turns it on with `holm settings set settings.apps..policy.email.app_send true`. Do not turn namespace availability into a permission assumption or embed provider credentials in the app. ### SMS refuses a send or remains queued - `SMS_SEND_NOT_CONFIGURED`: select a driver/credential binding; there is no automatic log fallback. `SMS_PROVIDER_UNREADY` or `SMS_CREDENTIALS_MISSING`: inspect `holm sms status --app --json`, the exact app/node binding scope and secret. A disabled/invalid app override does not fall back to the node. - `SMS_SEND_DISABLED`: inspect `sms.app_send` and zero-valued limits. Removing a provider override never clears the app off switch. - `SMS_GATEWAY_FORBIDDEN`: verify the full SMSGate URL and numeric gateway enrollment. Loopback needs no LAN switch; trusted-LAN HTTP requires the node opt-in. Do not bypass HTTPS, private-DNS or redirect guards with a global provider egress rule. - `SMS_SEND_SUPPRESSED`: honor node OR app opt-outs. Twilio 21610 suppression applies node-wide; consent-based Holm removal does not clear the provider's block. - `SMS_SEND_INVALID`, `SMS_TEMPLATE_INVALID` or `SMS_SEND_TOO_LONG`: use one E.164 number, valid text within the segment cap and exactly declared string variables. - `READ_EFFECT_DENIED`: move SMS send/opt-out effects into a write operation. - `SMS_SEND_INDETERMINATE`: never blindly retry. Inspect stored receipts and, for a recorded delivery, `holm sms status --delivery ` (one bounded GET). `queued` requires a running worker; `accepted` is not proof of handset delivery. SMSGate callbacks come from the phone, which needs reachable HTTPS and the correct signing key. ### A one-file backup is incomplete Stop Holm and materialize reachable detached bytes into a fresh standalone database. Verify `canonical_complete: true` before relying on that file. A mirror alone is not a complete cartridge or verified backup. ### Upgrade or restore is refused Do not bypass the refusal. Keep the existing binary and database. Review licence acceptance for the selected instance and restore into an alternate path with a compatible binary before choosing an active replacement. ## 24. Complete capability inventory This inventory covers the implemented v1 guide and verified operator command surfaces, not future promises from the specification. ### V1 app surfaces - Optional `holm.json`, static `web/` and private `api/` files. - `default { fetch(request, env) }` and Web Request/Response bodies. - Canonical static URLs, SPA fallback, nearest 404 pages and byte-range responses. - Symbol-based generated icons, manifest and social card; `iconStyle` choices. - Shared/member KV, DS and S3 with app and caller boundaries; shared DS search exclusions. - Read-only sign-in, node login/logout pages and exact-host cookies. - Fetch-handler realtime broadcast and browser `/_ws` subscriptions. - Private file reads, logging, secrets and saved environment values. - Policy-controlled HTTP, email, SMS, AI, images and guarded admin/node APIs. - Durable SMS admission, local templates and privileged opt-out bookkeeping, with node/app suppression union and per-app sending off switch. - Authenticated typed HTTP jobs with atomic receipts and declared native caller-scoped polling. - Explicit `--spec-v1` deploy and atomic file replacement. JS background work, schedules/cron, durable queues/events and native agents are not available in v1 yet. ### Node and operator surfaces - Official binary installation and upgrade; Linux service lifecycle and peer handoff. - Ready-to-deploy v1 app installation from a git address with checksum pinning. - SQLite instance selection, policy, settings, providers and secret stores. - Exact hosts, HTTPS, member/PAT/API-key administration and peer commands. - Branded or custom pages for hosts with no app. - Local/remote backups, restore verification, Object placement and retention preview. - Runtime logs and filtered, audited app analytics/log purges. - Egress rules, scan shield and disclosure policy. - SMSGate Android cloud/local/USB and Twilio bindings, stored masked SMS receipts, signed delivery callbacks and CLI template/opt-out commands. Provider-only HTTPS consent does not grant app fetch access. ## 25. Glossary **Admin host** : The node-management hostname, normally `admin.`. **App** : A hosted file tree plus an app-scoped identity, storage, policy and runtime. **App ID** : The durable opaque identity of an app. Host routes may move independently. **BFBB** : Build free, but buildable. Raw browser files need no mandatory build tooling. **Cartridge** : A complete SQLite database that can recreate the node without Object providers. **Canonical complete** : All reachable state and Object bytes needed by the node are in one database. **DS** : Holm's app-scoped JSON document store. **Host route** : An exact FQDN mapped to an app ID. **KV** : Holm's app-scoped key-value store with optional TTL and atomic counters. **Member** : A person or service principal with current authority. **Object** : Content-addressed bytes referenced by hosted files and app blobs. **PAT** : A personal access token representing a member and current member authority. **Peer** : A named Holm node targeted with `@peer`. **Policy** : A typed, durable gate or limit at node or app scope. **Provider** : A named implementation/config record for AI, email, Object storage or another external family. **S3** : The app runtime name for Holm blob storage; it does not imply Amazon S3. ## License and support posture Holm is closed source and distributed under HULA v1 at `https://license.holmhq.com/v1/`. Every pre-1.0 release is free for personal, internal and commercial use under those terms. The licence permits building, hosting and selling apps and client work, and redistributing exact unmodified official binaries subject to its conditions. Public managed/preconfigured Holm offerings, binary modification and misleading branding are restricted. Read the licence, not just this summary. The embedded licence ID and legal-text digest identify a build's terms. Changed terms require separate acceptance; an earlier build keeps the terms it identifies. Upgrades are not required. Holm is provided as is. Contact `license@holmhq.com` for licensing questions and `security@holmhq.com` for private vulnerability reports. ## Maintenance contract When refreshing this reference: 1. Record the actual binary version, date and public provenance. 2. Check command spellings and flags against that binary's help. 3. Keep app authoring limited to facts and examples in the implemented v1 guide. 4. Do not turn specification examples or unavailable names into shipped features. 5. Preserve the complete app and Fetch examples from the guide. 6. Verify generated docs and the byte-identical public reference projection. 7. Check the exact public artifacts for credentials, private paths, repository history and internal-only material. A help audit is not proof that a live app or operator procedure has run. Record runtime verification separately when it actually occurs. ## 26. Machine-oriented quick reference ### Detect and inspect ```bash holm --version holm peer list holm @ status holm @ host list --limit 1000 ``` Stop if the binary, peer, credential or requested domain is missing or ambiguous. Do not replace a domain already listed without permission. ### V1 folder ```text holm.json optional identity and static web settings web/ public pages, browser source and assets api/main.js default { fetch(request, env) } api/ private modules, templates and JSON ``` With neither `web/` nor `api/`, plain mode serves the retained folder. `holm.json` remains protected. ### Build, deploy and verify ```bash holm app validate ./my-app --spec-v1 holm @ app deploy ./my-app --spec-v1 --host app.example.com holm @ app request GET / holm @ app logs ``` Build locally only if needed. The node runs no build. Keep `--spec-v1` on every redeploy, and use `--force` only for an authorized replacement. ### Runtime choices ```text key/counter env.holm.app.kv queryable records env.holm.app.ds binary bytes env.holm.app.s3 member-private env.holm.app.member.* after sign-in identity/links env.holm.auth server-read files env.holm.private, relative to api/ realtime env.holm.realtime; browser /_ws outbound HTTP env.holm.net.fetch, with policy AI call env.holm.ai.chat, with provider and budget email env.holm.email.send, with provider and policy SMS env.holm.sms.send, with provider, consent, quotas and opt-outs SMS opt-outs env.holm.sms.optout.add/remove, privileged actor required ``` ### SMS setup checks ```bash holm help sms holm sms --help holm sms status --json holm sms receipts --limit 20 --json holm sms status --app --json ``` Choose the Android cloud/local/USB or Twilio recipe in section 6.7; read `bindingId` before configuring signed callbacks. No provider `net allow` step is needed. Status is configuration/presence; receipts are stored metadata. Never send to a number without the operator's consent. ### Reject dangerous assumptions - `--spec-v1` is required every time, even when the app is already v1. - Pass `env` to helpers; there is no global server `holm`. - Read the Web body once and return a Web `Response` with your own headers. - Only use the standard globals and APIs listed in section 11. - No node-side build or package import resolution. - Do not put secrets or private docs in `web/`; storage is private until served. - Test signed-out and member-private behavior, not just the public page. - A validation check is not a real-app run. - Never patch live SQLite or copy an incomplete database as a backup. ### Before a risky operator action Confirm the active database, selected peer and target version. Make and verify a complete backup. Use the documented preview when available. Keep the current binary and database until the new state has been verified.