Drive the entity REST API from the CLI — typed, authenticated, scriptable.
Every other remote command — apply, inspect, migrate — reaches the
database directly over a --db-url. entity and login are different: they
speak HTTP to a running server, carrying a Bearer PASETO token, and never
touch the database. That transport split is the whole point of this reference.
If you have been hand-building curl calls and minting offline tokens to script
a deployed instance, these commands replace that — with typed input, real output
formats, and stable exit codes you can branch on.
Log in once, then run any verb. The token is cached and picked up automatically on every subsequent call.
schemaforge login --server https://forge.agency.gov -u alice
# Password: ********
# ok logged in — expires_at 2026-05-24T18:00:00Z, roles [admin]
schemaforge entity create Contact --server https://forge.agency.gov \
--set first_name=Alice --set last_name=Stone --set 'tags:=["vip"]'
# ok created 01J...
schemaforge entity list Contact --server https://forge.agency.gov --eq status=active
# (aligned table)
# 12 of 42 entities- Transport: a Running Server, Not the Database
- Authenticate Once with
login - Token Sources (Ranked)
- Connection Flags
- Typing Fields with
--set - Filtering and Sorting
- Output Formats
- Exit Codes
- TLS
- Configuration:
[schema_forge.client] - Examples
entity and login are HTTP clients: they build a URL, attach a Bearer token,
and send a request to a deployed instance. There is no --db-url here — the
database is the server's concern, not the client's.
The request URL is composed mechanically:
<--server> + /api/<--api-version>/forge + <route>
With the defaults (--server http://127.0.0.1:3000, --api-version v1), the
base is http://127.0.0.1:3000/api/v1/forge. Pointed at a deployment with
--server https://forge.agency.gov, it becomes
https://forge.agency.gov/api/v1/forge.
Each verb maps to one method, one route, and one success status:
| Verb | Method | Route (under {base}) |
Success |
|---|---|---|---|
list |
GET |
/schemas/{schema}/entities |
200 |
query |
POST |
/schemas/{schema}/entities/query |
200 |
get |
GET |
/schemas/{schema}/entities/{id} |
200 |
create |
POST |
/schemas/{schema}/entities |
201 |
replace |
PUT |
/schemas/{schema}/entities/{id} |
200 |
patch |
PATCH |
/schemas/{schema}/entities/{id} |
200 |
delete |
DELETE |
/schemas/{schema}/entities/{id} |
204 |
login |
POST |
/auth/login |
200 |
replace sends a PUT (full entity — every required field must be present).
patch sends a PATCH (partial merge — only the fields you supply change).
login exchanges a username and password for a token, then caches it. Every
later entity call finds that cached token on its own — you authenticate once
per session, not once per command.
schemaforge login --server <url> -u <username> [--password-stdin] [--print-token] [--no-save]
login posts {username, password} to {base}/auth/login, reads back
{token, expires_at, roles}, and reports expires_at and roles to stderr so
you can confirm what you got.
Supplying the password. Interactive use prompts for the password (no echo);
for scripts, pipe it on stdin with --password-stdin. There is deliberately no
--password flag — a password on argv leaks through ps and shell history.
# Interactive
schemaforge login --server https://forge.agency.gov -u alice
# Non-interactive
printf '%s' "$FORGE_PASSWORD" | schemaforge login \
--server https://forge.agency.gov -u alice --password-stdinCaching. By default the token is written 0600 to
$XDG_STATE_HOME/schemaforge/token (falling back to
~/.local/state/schemaforge/token). Subsequent entity calls read it from
there as the lowest-precedence source (see §3).
| Flag | Effect |
|---|---|
--print-token |
Also write the raw token to stdout (capture it for another step) |
--no-save |
Skip the cache; the token lives only as long as your capture of it |
login acquires a token from credentials. This is distinct from
schemaforge token generate, which mints a token offline from a PASETO signing
key with no server round-trip — use token generate for CI identities and
service accounts, login for an interactive operator at a deployed instance.
The Bearer token is supplied through one of four ranked sources. The CLI checks them in order and uses the first that yields a token:
--token-stdin— read the token from stdin (highest precedence)--token-file <path>— read it from a fileSCHEMAFORGE_TOKEN— read it from the environment- cached
logintoken —$XDG_STATE_HOME/schemaforge/token(lowest)
There is no --token flag. A token passed as an argument leaks through ps and
shell history, so the CLI never accepts one on argv; all four sources above keep
the secret off the command line.
The token is never logged, even at -vvv — it is redacted from all debug output.
# CI: token arrives on stdin, never touches the process table
printf '%s' "$FORGE_TOKEN" | schemaforge entity list Contact --token-stdin
# Token in a 0600 file managed by your secret store
schemaforge entity list Contact --token-file /run/secrets/forge-tokenEvery entity verb and login share the same connection flags. Defaults point
at a local instance, so a development loop needs none of them.
| Flag | Env | Default | Purpose |
|---|---|---|---|
--server <url> |
SCHEMAFORGE_SERVER |
http://127.0.0.1:3000 |
Base URL of the running instance; the versioned path is appended |
--api-version <v> |
— | v1 |
API version path segment |
--token-file <path> |
— | — | Read the Bearer token from a file (see §3) |
--token-stdin |
— | — | Read the Bearer token from stdin (highest precedence) |
--ca-cert <path> |
— | — | PEM CA certificate for a private-PKI server certificate |
--insecure |
— | off | Skip TLS verification; warns on every call. Never in production. |
--timeout <secs> |
— | 30 |
Per-request timeout |
login ignores the token-source flags (--token-file, --token-stdin) — it is
acquiring a token, not presenting one.
The server is strict about types: a field declared integer rejects the string
"25". --set types your values so they arrive as the JSON the server expects.
It applies to create, replace, and patch.
Typed coercion. --set field=value (repeatable) infers the JSON type:
--set max_seats=25 # → JSON number 25
--set active=true # → JSON boolean true
--set name="Alice Stone" # → JSON stringRound-trip guard. Coercion is suppressed when turning a value into a number would lose information — so identifiers stay strings:
| Input | Becomes | Why |
|---|---|---|
zip=01234 |
"01234" |
Leading zero would not survive a round-trip |
account=900000000000000001 |
"900..." |
Large integer-like IDs stay strings |
price=9.99 |
9.99 (float) |
Round-trips exactly |
price=2.70 |
"2.70" |
Trailing zero would not survive a round-trip |
A value is coerced only if parsing and re-printing it yields the identical text; anything that would not round-trip stays a string.
Raw JSON with :=. For arrays, objects, and relation references, use the
:= form (httpie's escape) to supply explicit JSON:
--set 'tags:=["vip","gov"]' # array
--set 'owner:="user_01H..."' # string, forced
--set 'address:={"city":"Reston"}' # objectFull bodies with --data. Supply a complete body from a file, stdin, or a
literal:
--data @contact.json
--data - # read from stdin
--data '{"fields": {"first_name": "Alice"}}'A bare field map is auto-wrapped into {"fields": {...}}; a body that is already
wrapped is accepted as-is. --set overlays --data, so you can load a base
body from a file and override individual fields on the command line.
list accepts filters and sorts in two compatible forms. Both compile to the
same wire grammar; pick whichever reads better in your shell.
Raw operands. A field__op=value operand passes straight through to the
query string, 1:1 with the server grammar. A bare field=value is equality:
schemaforge entity list Contact age__gte=18 age__lt=65 status=activeConvenience flags. Each is repeatable and expands to the matching operator:
| Flag | Operator | Example |
|---|---|---|
--eq |
equals | --eq status=active |
--ne |
not equals | --ne status=archived |
--gt |
greater than | --gt age=25 |
--gte |
greater than or equal | --gte age=18 |
--lt |
less than | --lt age=65 |
--lte |
less than or equal | --lte score=100 |
--contains |
substring match | --contains name=smith |
--startswith |
prefix match | --startswith email=admin |
--in |
set membership | --in status=active,pending |
Sorting and shaping.
| Flag | Effect |
|---|---|
--sort <s> |
--sort -age,name (leading - = desc) or --sort age:desc |
--fields <l> |
CSV projection — return only these fields |
--limit <N> |
Maximum results to return |
--offset <N> |
Number of results to skip |
--no-count |
Set count=false (skip the total-count computation) |
--no-resolve |
Set resolve=false (skip relation display-name resolution) |
For boolean logic that query strings cannot express, use
schemaforge entity query with --filter (@file, -, or a literal JSON
object). The wire-level operator set, logical combinators (and/or/not),
dotted field paths, and type-coercion rules live in
docs/query-api-reference.md; this section covers
only the CLI surface that maps onto them.
Data goes to stdout; status and diagnostics go to stderr. That split is
what makes the CLI scriptable: pipe stdout to jq or cut, and the ok created
confirmations never contaminate your data stream.
--format human (default). list and query render an aligned table with
a footer:
12 of 42 entities # "N of M" when count is available
12 entities # "N entities" under --no-resolve / --no-count
get renders key/value pairs. Writes confirm to stderr:
ok created 01J...
ok updated 01J...
ok deleted Contact/01J...
--format json. The raw server response (EntityResponse /
ListEntitiesResponse) is written to stdout unmodified — directly pipeable into
jq.
--format plain. Tab-separated rows, for cut and awk.
--dry-run (on create, replace, patch, delete). Prints the resolved
method, URL, and JSON body — then stops. Nothing is sent. Use it to confirm what
a write would do before committing to it.
Every failure maps to a stable exit code. Branch on these in scripts instead of grepping output.
| Code | Meaning | Maps from | Tip |
|---|---|---|---|
0 |
Success | 2xx |
— |
1 |
General | 404, 409 conflict, unconfirmed delete |
Check the id; pass --yes for non-interactive delete |
2 |
Invalid arguments / input | 422 validation (grouped details[]), missing token |
Read the grouped field errors; run login if no token |
10 |
Connection | refused, DNS, TLS handshake, timeout | Check --server and that the instance is up |
12 |
Server | 5xx |
Server-side fault; check the instance's logs |
14 |
Authentication failed | 401 |
Run schemaforge login, or check the token |
15 |
Forbidden | 403 |
Authenticated, but the role lacks this permission |
The distinct codes let a script tell "your input is wrong" (2, carrying the
server's grouped 422 details[]) from "the server is broken" (12) from "you
are not allowed" (15).
The wider CLI also uses 3 (parse), 11 (migration), and 13 (verification)
for non-entity commands.
TLS verification is strict by default. A server presenting a certificate from a private PKI is verified against the CA you supply:
schemaforge entity list Contact \
--server https://forge.agency.gov \
--ca-cert /etc/pki/agency-root.pem--insecure skips verification entirely and warns loudly on every call. It
exists for throwaway local testing; never use it in production.
The TLS stack is FIPS-consistent: reqwest runs through rustls backed by the
process-default aws-lc-rs crypto provider — the same provider every other
TLS-using subsystem installs at startup.
Pin connection defaults in config.toml so day-to-day commands need no flags.
This section is client-only — a running server ignores it entirely.
Precedence is flags > environment > config > built-in defaults: a flag always wins over config, and config always wins over the shipped defaults.
[schema_forge.client]
server = "https://forge.agency.gov"
token_file = "~/.config/schemaforge/token"
ca_cert = "/etc/pki/agency-root.pem"
timeout_secs = 30With this in place, schemaforge entity list Contact talks to the agency
instance over the agency PKI with no flags at all; add --server … on any call
to override it for one invocation.
# Authenticate once
schemaforge login --server https://forge.agency.gov -u alice
# Create
schemaforge entity create Contact --server https://forge.agency.gov \
--set first_name=Alice --set last_name=Stone \
--set status=active --set 'tags:=["vip"]'
# ok created 01J...
# Get one
schemaforge entity get Contact 01J... --server https://forge.agency.gov
# List with filter and sort
schemaforge entity list Contact --server https://forge.agency.gov \
--eq status=active --gte age=18 --sort -created_at --limit 25
# Patch (partial)
schemaforge entity patch Contact 01J... --server https://forge.agency.gov \
--set status=inactive
# Replace (full — every required field present)
schemaforge entity replace Contact 01J... --server https://forge.agency.gov \
--data @contact-full.json
# Delete (confirm in scripts with --yes)
schemaforge entity delete Contact 01J... --server https://forge.agency.gov --yesschemaforge entity create Account \
--set max_seats=25 \ # number
--set active=true \ # boolean
--set zip=01234 \ # stays "01234" (leading zero)
--set price=9.99 \ # float
--set list_price=2.70 \ # stays "2.70" (trailing zero)
--set 'roles:=["admin","auditor"]' # raw JSON arrayschemaforge entity list Contact --format json --eq status=active \
| jq -r '.entities[].fields.email'schemaforge entity create Contact \
--set first_name=Alice --set last_name=Stone --dry-run
# POST https://forge.agency.gov/api/v1/forge/schemas/Contact/entities
# {
# "fields": { "first_name": "Alice", "last_name": "Stone" }
# }#!/usr/bin/env bash
set -euo pipefail
# Token from a secret store, on stdin — never on argv
if ! printf '%s' "$FORGE_TOKEN" \
| schemaforge entity create Contact \
--server "$FORGE_SERVER" --token-stdin \
--set first_name=Alice --set last_name=Stone; then
code=$?
case $code in
14) echo "auth failed — token expired; re-issue" >&2 ;;
15) echo "forbidden — service account lacks create on Contact" >&2 ;;
2) echo "validation failed — see details above" >&2 ;;
10) echo "cannot reach $FORGE_SERVER" >&2 ;;
esac
exit "$code"
fi