Skip to content

Manage metadata with the hf CLI

Run the full agent-authoring loop — log in to a target, write metadata to a cart branch, review the DDL and the changed files, publish, and query the result — from the hf CLI, without a browser or Studio. This is the headless counterpart of Plan and publish a cart branch, which shows the same routes over raw curl.

  • A running Henceforth server and its URL.
  • A principal holding admin.metadata.plan (for hf branch plan), admin.metadata.apply (for hf apply and hf branch publish), and admin.metadata.module.read (for hf branch diff). hf branch plan also needs global admin.metadata.object.read and admin.metadata.field.read: the computed diff names every module, object and field it touches, so admin.metadata.plan alone is not enough — a principal with the plan grant but no read grants gets 403. All of these are scoped globally, not per-module. Writing directly to main (hf module|object|field create|update|delete --branch main) requires admin.metadata.apply too, in addition to the operation’s own permission — a create/update/delete-only principal gets 403 on --branch main. Every step below writes to a cart branch instead, which needs no apply grant at all; this walkthrough works the same whether or not your principal holds admin.metadata.apply, until the plan and publish steps.
  • The hf binary on your PATH. hf --version confirms it is installed.

Add --json to any command below to print the server’s response body verbatim instead of the human-readable rendering shown here.

Terminal window
hf login my-server https://your-server --ssh

Drop --ssh to sign in with a username and password instead. On an instance nobody has claimed yet, --ssh claims it: hf asks for an administrator name, confirms, and signs in with the key the instance was started with (see the authentication architecture for why).

This stores a target named my-server with the token you authenticate with, and makes it the current target if none is set yet. Every later command in this walkthrough runs against the current target; pass -t my-server (or any target name) to override it, or -t a different target to point the same commands at a different environment.

Terminal window
hf module list
hf object list core
hf field list core.Account

Each listing prints the dotted key path of every entry with its label; the path is what the other verbs take as their subject. hf object list and hf field list with no argument list across every module. hf object show core.Account prints one record.

create, update and delete all require --branch explicitly — there is no implicit main, so writing straight to production is a deliberate --branch main, never the default:

Terminal window
hf module create --branch feat-x sales -f sales-body.json5
hf object create --branch feat-x sales.Lead -f lead-body.json5

create maps to the server’s POST (fails with 409 METADATA_RESOURCE_EXISTS if the resource already exists); update maps to PUT (fails with 404 if it does not). The file is the request body only — the resource’s own fields (label, description, and so on) — not the resource’s key: moduleKey/objectKey come from the sales/sales.Lead argument on the command line, and the write routes reject a body that also includes them. A field body is the exception: it is a whole field definition, and its fieldKey must match the path. See the HTTP API reference for each resource’s exact request body shape. As many writes as needed land on the same branch before review — each is its own commit on feat-x.

Terminal window
hf branch plan feat-x

Lists the changes publishing feat-x would make: each change’s target and risk label, with its DDL statements indented underneath (every line of a multi-line statement carries the same indent), an aggregate summary, and, first, a line naming the branch and the exact commit the plan was computed against. Nothing has run yet — hf branch plan exits 0 whether or not the plan is empty, because a non-empty plan is not itself an error.

Plan never hides a change. Every change carries a disposition — execute, skip, or refuse — computed server-side from the same predicate the publish/apply gate itself uses, so a client reads it directly instead of deriving “would this be refused?” from risk or any other field. hf branch plan renders it as a label before the risk label: [SKIPPED] for skip, [REFUSED] for refuse, nothing for execute. An orphan drop (a column, junction table, or module schema no longer in metadata) is skip under this run’s options — still listed, with its DDL and risk; the summary line’s trailing skipped: N counts them. Publishing without --destructive leaves a skip change in place (with a warning), rather than running it; add --destructive to hf branch plan to see what it would do.

The managed Lookup-to-Junction FK column drop a relationship-strategy migration itself already decided is safe is always execute, even without --destructive — never skip. A change rated destructive risk that apply cannot skip is what makes disposition: refuse appear: an unsafe USING type narrowing (for example an integer field going from big to regular) or a generated column’s storage mode changing (a drop-and-re-add). Publishing refuses the whole run without --destructive when any change in the plan carries disposition: refuse, naming each such change in the error, rather than leaving just that one change in place.

Terminal window
hf branch diff feat-x

Lists the files the branch adds, modifies, or deletes relative to main, independent of what DDL those changes imply. This only needs admin.metadata.module.read — a reviewer who can read metadata but not apply it can still see what changed.

Terminal window
hf branch publish feat-x

On a terminal, this confirms before publishing (skip the prompt with --yes, required when running non-interactively or with --json — the same non-interactive guard hf apply --destructive uses, so a script or an agent can never hang on a prompt it cannot answer). Publishing fast-forwards main onto the branch and applies it in one step; the response is the same shape hf plan/hf apply render, with status: applied and the branch and commit that were published named in the source line. On success the branch is deleted and main’s HEAD has advanced.

Terminal window
hf query "SELECT id, name FROM core.Account LIMIT 10"

hf query parses the HOQS text locally before any network request: a syntax error renders with the parser’s own caret diagnostics and never reaches the server. A valid query prints a column-aligned table (or, for a query read from a file, hf query -f query.hoqs) with a trailing N of M line and a hint when more rows are available.

hf plan (planning the server’s own working tree) now reports no changes for whatever feat-x added — its change list is empty, because main already has what the branch had. hf object show sales.Lead reads the published object.

  • Publishing an unknown branch answers 404 BRANCH_NOT_FOUND; it never creates the branch as a side effect of the lookup.
  • If a branch’s metadata fails to compile, publish answers 422 naming every diagnostic and leaves main unmoved.
  • A branch id containing / (feat/x) is percent-encoded automatically by every verb that takes --branch or a positional branch argument — the server’s write routes require it as a single path segment.
  • A module key may be vendor-qualified: hf object show acme.crm.Account reads the Account object of the acme.crm module. The object key is the first segment that starts with an uppercase letter. Vendor modules are read-only, so the writes take a bare module key.
  • --json prints the server’s success body verbatim with exit 0, or its {"errors": [...]} envelope verbatim on stderr with exit 1 on failure — no wrapping, no re-serialization, so a script can pipe the output straight into jq.