Skip to main content
The engini CLI is built to be driven programmatically. Everything on this page is a stable contract of the npm distribution (@engini/cli).

Exit codes

No exit code changed in @engini/cli 0.16.0. The table above is unchanged from previous releases; this one adds commands and a flag, not new failure modes. The new connections update, replace, defaults, set-default, and clear-default commands use the existing codes: 2 for a missing or malformed argument, 4 for an unknown connection, 5 for a server-side validation failure (including a connections replace whose activity cannot be remapped onto the target).

MCP server commands

No exit code changed in @engini/cli 0.18.0. The engini mcp group reuses the table above; it adds commands, not failure modes. A not-found error reads Unknown MCP server token. and does not echo the token back - it is secret-equivalent.

On-prem agent commands

No exit code was added in @engini/cli 0.19.0. The engini opa group reuses the table above. Two deliberate choices in that table:
  • OPA_AGENT_OFFLINE is 1, not a new code. The agent being offline is a retriable operational state, not something the caller did wrong. The exit-code set is published and scripted against; it grows only when a new kind of failure appears, and “the thing you asked for is not reachable right now” already has a code.
  • update against an offline agent exits 0. The change is persisted; applied_to_agent: false in the output is how you learn the agent has not seen it yet. Failing would hide a committed write and leave the caller unable to tell whether retrying is safe.

Trigger commands

No exit code was added in @engini/cli 0.20.0. The engini triggers group reuses the table above. One code’s meaning is newly assigned: a resume cursor that retention has pruned (410 SINCE_PRUNED) exits 4, not 1 — “the thing you asked for no longer exists” is a not-found, even though the status is 410. Three deliberate choices in that table:
  • 410 SINCE_PRUNED is 4, and it is the only 410 in the group. The gap error is recognised by its wire code, not by its HTTP status — there is no general 410 row to script against.
  • A subscribe failure (502) and a stream-limit rejection (429) are 1. Both are operational states rather than caller errors, and the exit-code set is published and scripted against; it grows only when a new kind of failure appears.
  • A failed --forward is the receiver’s failure, not the stream’s. listen exits 1 only when it ended on its own and at least one forwarded POST failed. A forward failure never kills the stream mid-run; the count is reported in the summary line.
Interrupting listen exits 0 (from 0.23.0). Ctrl-C closes the stream and any in-flight --forward POST, prints the usual summary line, and exits 0 even if some forwards had failed - you asked it to stop and it stopped. Only a stream that ended on its own with failed forwards exits 1.
On 0.20.0-0.22.x, Ctrl-C does not stop listen. The interrupt is only noticed between stream frames, so an idle stream never notices at all. Upgrade to 0.23.0 or later; on an older version, kill it from outside (pkill -f "engini triggers listen").

Enum-valued flags

--kind (on engini connections list) accepts Regular, Key, or MCPClient, case-insensitively - --kind key is valid. The CLI does not validate the value locally. It forwards whatever you pass, and the server rejects an unrecognised value with a 400 that lists the accepted ones, surfacing as exit 5. That keeps one authority for the list: a client-side copy would be a second place to update whenever the enum grows. On engini connections list, --kind MCPClient is accepted and exits 0 with an empty array - that endpoint serves only Regular and Key connections. --kind OPA exits 5 since @engini/cli 0.19.0: the server rejects it with INVALID_ENUM_VALUE and a message naming /v1/on-prem-agents. On-prem agents were never served by that endpoint, so no caller can have depended on the old empty result; the value was removed rather than left pointing at nothing. Use engini opa. --status (on engini opa list) follows the same pattern: WaitingForConnection, Online, Offline, Disabled, case-insensitive, validated server-side, unknown value exits 5.

Interactive prompts

  • Declining a confirmation ([y/N] → n) exits 0. You were asked, you said no, nothing went wrong.
  • Aborting a prompt with Ctrl-C or Ctrl-D exits 2, as does stdin ending before you answered. A cancelled prompt makes no claim about what a partly-completed command already did - engini connect, for example, may already have created the connection server-side.
  • A command that needs to prompt but can’t exits 2. Prompting requires a terminal on both stdin and stdout; pass the values as flags (--api-key, --field, --force) when either is piped.

Output modes

  • TTY: human-readable output; progress lines go to stderr.
  • Piped or --json: pretty JSON on stdout - always machine-parseable.
Output format follows stdout; interactivity follows both streams. They are separate decisions, so a caller that pipes only stdin still gets human-formatted output - including need:* payloads. Pass --json whenever you intend to parse the result and stdout might be a terminal.
  • --quiet: suppresses non-essential output.
  • Errors are structured too: a failure prints a JSON error object with a hint where applicable.
engini triggers listen is the one exception to “format follows stdout”. It prints one compact JSON line per event, always — on a terminal, piped, with or without --json. A stream has no end to pretty-print at, and a line-oriented stream is what jq and while read want, so the format is fixed rather than context-dependent. It closes with a single summary line, {"events": n, "forwarded": n, "failed": n}.

Introspection: --schema

Every command supports --schema, printing its argument and output schema as JSON - agents discover flags without parsing help text:

Resumable gates: need:* + exit 5

engini connect never blocks for a non-interactive caller. “Interactive” means a terminal on both stdin and stdout - every gate that would prompt needs stdin to read the answer, so piping stdin alone is enough to take the machine path. At each point where it needs input it prints a self-describing payload and exits 5:
Each gate payload contains everything needed to proceed (methods, fields, sign_in_url, objects…) plus a resume string with the exact next command. The loop is: run → read need → obtain the value (ask your user) → run resume. Any --field flags you originally passed are echoed back into resume so replaying it never drops them; a secret field’s value (isPassword or maskInLogs) is shown as <value> rather than the real secret. A resume replayed after state_expires_in_seconds exits 4 (not found) - the OAuth sign-in state has been purged, expired, or never existed, and the CLI fails fast on the first poll rather than waiting out the full --timeout. Start a new engini connect <app>.

Field options in need fields and need auth

A field’s entry in either payload’s fields array can carry two different things about its legal values, and they mean different things:
  • list_values - the raw, possibly-null string straight off the application’s static schema (AppConnectionField.ListValues). Never parsed by the CLI; a caller that wants a structured list has to parse it itself.
  • options - the OAuth terminal poll’s dynamically-discovered map for a field with no static list (e.g. Salesforce’s ApiVersionUrl), shaped the same as the SDK’s fieldOptions - see Field options discovered after sign-in for the orientation warning. Only ever populated on the OAuth path; a --field/direct-credential method never signs in, so there’s no org to discover options from.
options: null means two different things depending on which payload it’s in - both fall out as the same JSON, so don’t conflate them. In need:"auth", null means not knowable yet - no sign-in has happened, so there is no org to ask. In need:"fields", null means there’s nothing dynamic to offer for this field right now - either an OAuth sign-in ran and the lookup found none or couldn’t complete, or the field’s method (e.g. --field/direct-credential, which never signs in) never does that lookup at all. Either way, fall back to list_values, if any, or free text. Neither null means “this field takes anything” if the field is otherwise constrained.
When options is present and non-empty for a field, the value you pass via --field <fieldName>=<value> must be one of its keys - never a label. An unrecognised value fails fast, client-side, before any request reaches the server:
Exit 2 (usage), not 5 - the legal set is already in hand from the payload above, so a bad value is a caller mistake the CLI can catch locally, not a server-side validation round-trip. In a TTY, the same field prompts as a numbered picker instead of free text, so you never type the raw value.
Requires @engini/cli 0.24.0 or newer (0.23.0 and earlier neither surface options on a field nor validate --field against it).

Result handles

Tool results larger than --max-bytes (default 4096) are spilled to a local sandbox and replaced by a compact envelope with a handle and a shape preview. Drill in instead of re-fetching:
Stored results live under $XDG_CACHE_HOME/engini/results/ (%APPDATA% on Windows) and are auto-pruned to the most-recent 20, dropping anything older than 24h - handles are short-lived, so drill in during the same session. --inline (or --max-bytes 0) forces the full result inline; --raw / --llm bypass the sandbox.

LLM-shaped output

engini tools call ... --llm openai|anthropic --tool-call-id <id> emits the result already formatted as that vendor’s tool-result message - pipe it straight back into the model conversation.

Previews

Mutating commands accept --dry-run, printing what would happen (e.g. {"would_connect": ...}) without executing. Every mutating engini mcp command previews under --dry-run, and for the set-replacing ones the preview is the safety mechanism - it shows the whole resulting set, not just your edit:
changed exists because a connection kept on both sides but narrowed or widened in tool scope is neither added nor removed - matching on id alone would hide exactly the binding loss a preview is for. Tool-slug order is ignored, so re-listing the same tools in a different order is not reported as a change.

engini mcp output shapes

The engini mcp group prints snake_case keys, mapping one-to-one onto the camelCase members of the /v1/mcpservers responses.
mcp_server_token is the {server-token} component of the server’s SSE URL. Redact it in anything you log, publish, or attach to a ticket.
engini mcp list - a JSON array of server summaries. Deactivated servers are included, with is_active: false. Pagination is handled for you. engini mcp get <token> - one object: the summary plus url, updated_date, created_by_account_user_id, updated_by_account_user_id. Every mutating command in the group also prints the updated summary on success. connections[]: workflows[]: engini mcp tools list --connection-id <id> - a JSON array of {"tool_name": string, "description": string | null, "is_mandatory": boolean}. A mandatory tool is exposed regardless of the allow-list.

Calling /v1/mcpservers directly

Four operations over three paths, all account-scoped by your API key:
  • GET /v1/mcpservers returns the standard envelope: {"items": [...], "totalCount": n, "offset": n, "top": n}. top is silently clamped to 1-100 (a missing, zero, or negative top becomes 100), and a negative offset is treated as 0 - neither is an error, so do not infer a page size from what you asked for. Read offset/top back off the response.
  • PATCH leaves any omitted member unchanged. A supplied connections or workflows list replaces the whole set - it does not merge. Send one connection and every other connection on that server is dropped. Read the server, modify the list you got back, and send all of it. The CLI does this read-modify-write for you; a direct caller must do it themselves.
  • There is deliberately no create and no delete. Authoring and destroying an MCP server stay in the engini.io UI, so POST /v1/mcpservers and DELETE /v1/mcpservers/{mcpServerToken} do not exist.
  • isAnonymousAllowed is read-only and is preserved by every update.
  • Every connection entry must carry an explicit connectionId. MCP servers are account-wide, so there is no per-user default connection to resolve one from. An applicationSlug sent alongside it must match, or the request is rejected with 400.
  • An mcpServerToken belonging to another account returns 404, never 403 - the same answer as one that does not exist.

engini opa output shapes

Snake_case keys mapping one-to-one onto the camelCase members of the /v1/on-prem-agents responses. Agent ids are not secrets and are echoed in error messages; agent tokens are, and appear only as the deliberate output of create, token get, and token rotate. engini opa list - a JSON array of agent summaries. engini opa get <id> - the summary plus description, settings, created_date, updated_date. create, update, enable, and disable also print an agent object on success. settings: engini opa logs <id> -o <file> - {"wrote": string, "bytes": number, "agent_id": number}. The archive goes to the file, never to stdout. engini opa download - with --json, the raw manifest: {"version", "platform", "url", "sha256", "sizeBytes", "releasedAt", "degraded"}. Without it, a receipt: {"wrote": string, "bytes": number, "version": string, "verified": boolean | null, "degraded": boolean}. verified is true when the file matched the manifest’s sha256, false when the manifest was degraded and carried none. Previews (--dry-run) - {"would_create": {...}}, {"would_update": id, "changes": {...}} (only the members you passed - this is the merge, made visible), {"would_delete": id}, {"would_enable": id}, {"would_disable": id}, {"would_rotate_token_for": id}, {"would_write": path, ...} for logs and download. token get has no --dry-run: it is a read, and no read in this CLI has one.

Calling /v1/on-prem-agents directly

Eleven operations over eight paths. All are account-scoped by your API key except GET /v1/on-prem-agents/releases/latest, which is anonymous. Every {id} route answers 404 for an unknown id, another account’s agent, or an id that is a regular connection. The three are indistinguishable by design.