CLI
Scripting and CI
The CLI is built to be driven by a script: JSON on stdout, everything else on stderr, exit codes that mean something, and a credential that comes from the environment rather than a file on disk.
The --json contract
Two rules the whole CLI obeys, so a pipeline never has to pattern-match English:
--jsonwrites only a JSON document to stdout. Notes, warnings, prompts, progress bars and errors all go to stderr.- Colour and box drawing are dropped when stdout is not a terminal, so even the human output stays diffable.
engram corpora list --json | jq -r '.[] | "\(.id)\t\(.name)\t\(.status)"'
--json works before or after the subcommand, so it can be set once at the top of a wrapper. Some commands carry a JSON document even on failure, which is the point: a script driving pushes in bulk has to tell a quota wall from a network blip exactly, not approximately.
engram push ./docs --corpus "Support KB" --json > result.json || true
jq -r '.error_status, .error_code, .error' result.json
A failed push emits corpus_id, source, error, error_status and error_code. A successful one emits the counters, the sync run id and its state. engram status --json emits a usable document even when the API is unreachable, carrying api_ok: false and api_error, so a monitoring script gets the reason rather than an empty stdout.
Exit codes
| Code | Means | Typical cause |
|---|---|---|
0 | Success | The command did what it said. |
1 | The operation failed | An API error, a refused credential, an unreachable control plane, a push that did not fully succeed, a sync run that ended badly. |
2 | Usage, before anything was sent | A missing required option, a key that cannot be an HTTP header, a plain http URL, no API key with nobody at the keyboard, --file pointed at a folder. |
Exit 2 is worth handling separately: it means nothing left the machine, so retrying will produce the same result until the command line or the environment changes. Exit 1 is a condition worth a retry or an alert.
engram status is designed as a gate. It exits 1 when the API is unreachable, when nobody is logged in, or when the key is refused, so this is a real check:
engram status >/dev/null || { echo "engram unavailable"; exit 1; }
engram push ./docs --corpus "Support KB"
Credentials in a pipeline
Do not run engram login in CI. The environment variables take precedence over any stored profile and write nothing to disk, which is the right shape when the key comes from a secret store:
export ENGRAM_API_KEY="$ENGRAM_KEY"
export ENGRAM_API_URL="https://api.engramdynamics.org" # optional, this is the default
engram status --json
Scope the key to what the job does, and give each job its own so revoking one does not stop the others:
engram keys create --name ci-docs --scope ingest --json | jq -r '.key'
Two guards fire before any request when the credential is wrong, and both exit non-zero with a sentence rather than a traceback. A key with a non-ASCII character in it is refused because HTTP headers cannot carry it. And an ENGRAM_API_URL on plain http is refused at use time, on every command, because it never passed through login where --insecure is granted. Only localhost, 127.0.0.1 and ::1 are exempt.
engram login with no key and no terminal exits 2 with No API key given. Pass --api-key or set ENGRAM_API_KEY, rather than waiting at a hidden prompt forever. That is the behaviour you want in CI, and it is why the variables are the supported path.
A worked example: GitHub Actions
Push a docs folder on every merge, and fail the job if the load did not land:
name: Publish docs to Engram
on:
push:
branches: [main]
jobs:
publish:
runs-on: ubuntu-latest
env:
ENGRAM_API_KEY: ${{ secrets.ENGRAM_API_KEY }}
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.12"
- name: Install the Engram CLI
run: pipx install engram-dynamics
- name: Check the platform is up
run: engram status
- name: Push the docs folder
run: |
engram push ./docs \
--corpus "Support KB" \
--exclude 'drafts/**' \
--json > push.json
- name: Summarise
if: always()
run: jq -r '.counts, .state, .error // empty' push.json
Nothing is committed unless every byte landed, so a failed step leaves the base as it was and re-running the job finishes the work. That makes the retry button safe.
Patterns worth copying
Wait for onboarding, then do something else
push waits by default and exits non-zero if the run did not succeed, so the sequencing is just shell:
set -euo pipefail
engram push ./docs --corpus "Support KB" --json > push.json
echo "sync run: $(jq -r '.sync_run_id' push.json)"
./notify-team.sh
Stage a large load, build later
engram push ./archive --corpus "Support KB" --no-onboard --json > staged.json
# ... in the build window ...
engram sync --corpus "Support KB" --wait
Check a base before pointing traffic at it
ready=$(engram status "Support KB" --json | jq -r '.status')
[ "$ready" = "ready" ] || { echo "not ready yet"; exit 1; }
Feed a generated document straight in
./render-release-notes.py | \
engram docs upsert --corpus "Support KB" --path notes/release.md --text -
Gotchas
- Names have to be unique to be usable. Two bases called "Support" makes every
--corpus Supportfail with the candidate ids listed. Use the id in a script, or keep names distinct. - A timed-out wait is not a failure of the load.
--timeoutcaps how long the CLI watches, not how long the server works. The message says the upload is committed and points atengram status. - Console encoding on Windows. The CLI puts stdout and stderr on UTF-8 before rendering anything, so a document base named in any language renders on a legacy code page instead of dying mid-table. Nothing to configure.
- Progress bars vanish in a pipeline. They are suppressed with
--jsonand whenever stderr is not a terminal, so a build log gets a summary rather than 20,000 redraws. - The API contract is pinned per profile. An installed CLI stays on
legacyunless told otherwise, so a server rolling forward cannot change your pipeline's behaviour.engram statusprints the version in play.