C1 Interface — and it looks like cli. Get it?
Beta — stabilizing toward 1.0. The core commands, flags, and output formats are stable; smaller changes may still land before 1.0.
A command-line interface for the C1 API designed for AI agents. Structured output (NDJSON/JSON), built-in API docs, and auto-pagination. For a human-friendly CLI, see cone.
# Install
go install github.com/ConductorOne/c1i@latest
# Log in (opens browser)
c1i auth login --url mycompany.conductor.one
# List users
c1i users list
# Explore the API — no credentials needed
c1i docs search "access reviews"
c1i docs endpoints --filter taskc1i users list [--query <text>] [--email <email>] [--status enabled|disabled|deleted] [--page-size N] [--page-token TOKEN] [--limit N]
c1i users get <user-id>c1i apps list [--page-size N] [--page-token TOKEN] [--limit N]
c1i apps get <app-id>c1i accounts list --app-id <id> [--status enabled|disabled|deleted] [--type user|service_account|system_account] [--unmapped-only] [--query <text>] [--page-size N] [--page-token TOKEN] [--limit N]
c1i accounts set-owner <app-user-id> --app-id <id> --user-id <id>c1i entitlements list [--app-id <id>] [--query <text>] [--page-size N] [--page-token TOKEN] [--limit N]
c1i entitlements get <entitlement-id> --app-id <id>c1i grants list --app-id <id> --entitlement-id <id> # who holds an entitlement
c1i grants list --user-id <id> # what a C1 identity has, across apps
c1i grants list --app-user-id <id> # what an app account holds
c1i grants list --app-id <id> # every grant in an appGrants are the bindings between accounts/users and entitlements. At least one
filter is required. Each NDJSON row includes the entitlement, the account
(app_user_*) and its identity_user_id, timestamps (created_at,
deprovision_at), and grant_source_count — 0 for a direct grant, or the
number of groups/roles the access is inherited through.
A grant outlives the entitlement or account it points at, so rows also carry
entitlement_deleted_at and app_user_deleted_at: jq 'select(.entitlement_deleted_at)' finds grants whose backing object is gone.
Absent timestamps are null, never "".
c1i tasks list [--state open|closed] [--query <text>] [--assigned-to-me] [--page-size N] [--page-token TOKEN] [--limit N]
c1i tasks approve <task-id> [--policy-step-id <id>] [--comment <text>]
c1i tasks deny <task-id> [--policy-step-id <id>] [--comment <text>]
c1i tasks comment <task-id> --comment <text>approve/deny target a specific policy step. If --policy-step-id is
omitted, the task's currently executing step is fetched and used automatically
for both — but approve requires a resolvable step and errors if it can't
find one, while deny proceeds without a step if none can be derived.
c1i connectors list --app-id <id> [--page-size N] [--page-token TOKEN] [--limit N]c1i functions list [--published-only | --draft-only] [--page-size N] [--page-token TOKEN] [--limit N]
c1i functions get <function-id>
c1i functions source <function-id> [--commit <id>] [--out-dir <path>]
c1i functions commits <function-id> [--page-size N] [--page-token TOKEN] [--limit N]
c1i functions usage <function-id> [--page-size N] [--page-token TOKEN] [--limit N]functions source auto-resolves the function's published commit (falling back to its head/latest draft) and base64-decodes the source files. Without --out-dir, each file is printed to stdout with a // ===== <name> ===== delimiter; with --out-dir, files are written to disk. Fetched source is developer-authored code that commonly inlines credentials, so files are written 0600 and the directory is created — or, if it already exists, tightened, never widened — to at most 0700 (owner-only; group access to the directory would buy nothing since the files inside are already unreadable to group/other, and a filename alone can be informative). Any setuid/setgid/sticky bit on the directory is stripped outright, since none of them mean anything once there is no group or other access left. A tightened pre-existing directory prints a warning to stderr naming its old mode. functions usage scans every automation and emits one row per step that calls the given function — useful before deleting a draft.
c1i automations list [--enabled-only] [--calls-function <fid>] [--page-size N] [--page-token TOKEN] [--limit N]
c1i automations get <automation-id>
c1i automations executions list [--state done|error|pending|...] [--template-id <tid>] [--page-size N] [--page-token TOKEN] [--limit N]Each automations list row includes function_ids (every distinct function the automation invokes), so --calls-function can answer "which automations call function X?". executions list --state accepts the short forms (done, error, pending, ...) or the full AUTOMATION_EXECUTION_STATE_* enum; state and template filtering are applied client-side, so pair a narrow filter with --limit to bound the work.
c1i policies list [--page-size N] [--page-token TOKEN] [--limit N]
c1i policies get <policy-id>
c1i policies search [--query <text>] [--display-name <name>] [--policy-type grant|revoke|certify ...] [--include-deleted] [--exclude-policy-id <id> ...] [--page-size N] [--page-token TOKEN] [--limit N]
c1i policies create --display-name <name> --policy-type grant|revoke|certify [--description <text>] [--steps-file <file|-> ] [--rules-file <file|-> ] [--allow-deny-all]
c1i policies create --body-file <file|->
c1i policies update <policy-id> [--display-name <name>] [--description <text>] [--policy-type ...] [--steps-file <file|-> ] [--rules-file <file|-> ] [--allow-deny-all]
c1i policies update <policy-id> --body-file <file|-> --update-mask <paths>
c1i policies delete <policy-id>
c1i policies validate-cel <condition>A policy describes how C1 processes a task: who approves it (an ordered list
of policySteps, each a oneof of approval/provision/accept/reject/wait/form —
the schema also declares action, which the server rejects as an unsupported
step type — and an approval step's approver is itself a oneof of ten arms:
users, manager, group, appOwners, self, entitlementOwners, expression, webhook,
resourceOwners, agent), and how rules[] route a task to one of several
step sequences by CEL condition. That structure is too deeply nested for a
flag surface, so create/update take it from a JSON file (or - for
stdin) via --steps-file/--rules-file/--body-file — the same pattern
mcp servers register uses for its auth config.
Client-side guards (create and update, before any request is sent,
exit code 2) exist because several C1 policy API defects are either silent
or return an opaque HTTP 500 instead of a 400:
- Empty/missing steps for a policy's baseline entry are refused —
POST /api/v1/policieswith nopolicyStepssucceeds and silently returns a deny-everything policy (a single{"reject":{}}step), with no validation error. Pass--allow-deny-allif that's genuinely what you want (an explicitsteps:[]is refused regardless — the server 500s on that, not a safe default). --policy-typeunspecified, an emptyrules[].condition(needs the literal"true"for a baseline/catch-all rule), aprovisionstep,fallback/fallbackUserIdson an approver arm that doesn't support them (onlyusers,appOwners,webhook, andagentlack it — the other six arms each support their ownfallback/fallbackUserIds/fallbackGroupIds/isGroupFallbackEnabled), andfallback:truewith nothing to fall back to (a bare server error that surfaces asHTTP 500).
update sends the API's required {"policy": {...}, "updateMask": "..."}
wrapper for you — a flat body 400s. The --steps-file/--rules-file
convenience flags derive the update mask from what you pass; --body-file
requires an explicit --update-mask.
validate-cel checks a CEL condition without creating or updating anything.
Its root variable is subject (not user); this validates the
rules[].condition environment specifically, which is NOT the same
environment ExpressionApproval.expressions run in (see the command's
--help). An invalid condition prints its compile markers and exits 2, so
c1i policies validate-cel '<cond>' && ... only continues on a condition
that compiles.
A soft-deleted policy still returns from a direct get (with deletedAt
populated) but disappears from list and the default search; only
search --include-deleted finds it there. List and search rows carry
deleted_at so deleted rows are distinguishable without a second call — it
is null on a live policy, so jq 'select(.deleted_at)' selects only the
deleted ones.
Drive the MCP admin surface (servers, tools, toolsets, and bindings). Most commands take --app-id; mcp servers commands take the server's <connector-id> positionally, while tool/toolset/binding commands scope to a server with --connector-id.
# Servers (register, configure, and inspect MCP servers)
c1i mcp servers list --app-id <id> [--page-size N] [--limit N]
c1i mcp servers get <connector-id> --app-id <id>
c1i mcp servers search --app-id <id> [--query <text>] [--tool-state approved|pending|disabled|removed] [--include-last-called-at] [--limit N]
c1i mcp servers register --app-id <id> --type hosted --display-name <name> --catalog-id <cid> [--auth ... ] [--config-field k=v ...]
c1i mcp servers register --app-id <id> --type external --display-name <name> --server-url <url> [--transport streamable-http|sse] [--auth ...]
c1i mcp servers update <connector-id> --app-id <id> [--display-name <name>] [--description <text>] [--data-sensitivity ...] [--tool-prefix <p>] [--require-tool-approval]
c1i mcp servers update-credentials <connector-id> --app-id <id> --type hosted|external [--auth ...] [--update-mask <paths>]
c1i mcp servers delete <connector-id> --app-id <id>
c1i mcp servers resync-tools <connector-id> --app-id <id> # EXTERNAL only; 400 on HOSTED
c1i mcp servers test-connection (--server-url <url> [--transport ...] [--auth ...] | <connector-id> --app-id <id>) # EXTERNAL only; 400 on HOSTED
c1i mcp servers discover-oidc --issuer-url <url>
c1i mcp servers catalog list [--query <text>] [--page-size N] [--limit N]
c1i mcp servers catalog get <catalog-id>
c1i mcp servers connections list [--page-size N] [--limit N]
# Tools
c1i mcp tools list --app-id <id> --connector-id <id> [--page-size N] [--page-token TOKEN] [--limit N]
c1i mcp tools get <tool-id> --app-id <id> --connector-id <id>
c1i mcp tools search --app-id <id> --connector-id <id> [--query <text>] [--state ...] [--classification ...] [--page-size N] [--limit N]
c1i mcp tools approve <tool-id> --app-id <id> --connector-id <id> [--state approved|disabled|pending]
c1i mcp tools delete <tool-id> --app-id <id> --connector-id <id>
c1i mcp tools history <tool-id> --app-id <id> --connector-id <id> [--page-size N] [--limit N]
# Toolsets (admin-curated tool groupings; one AppEntitlement per toolset)
c1i mcp toolsets list --app-id <id> --connector-id <id> [--page-size N] [--limit N]
c1i mcp toolsets get <toolset-id> --app-id <id> --connector-id <id>
c1i mcp toolsets create --app-id <id> --connector-id <id> --display-name <name> [--description <text>]
c1i mcp toolsets update <toolset-id> --app-id <id> --connector-id <id> [--display-name <name>] [--description <text>]
c1i mcp toolsets delete <toolset-id> --app-id <id> --connector-id <id>
c1i mcp toolsets get-by-entitlement <app-entitlement-id> --app-id <id>
c1i mcp toolsets requestable-connectors <user-id>
# Bindings (which tools belong to which toolset)
c1i mcp bindings list --app-id <id> --connector-id <id> --toolset-id <tid> [--page-size N] [--limit N]
c1i mcp bindings create --app-id <id> --connector-id <id> --toolset-id <tid> --tool-id <id> [--tool-id <id> ...]
c1i mcp bindings delete --app-id <id> --connector-id <id> --toolset-id <tid> --tool-id <id> [--tool-id <id> ...]
c1i mcp bindings by-tools --app-id <id> --connector-id <id> --tool-id <id> [--tool-id <id> ...]
c1i mcp bindings history --app-id <id> --connector-id <id> (--toolset-id <tid> | --tool-id <id>) [--page-size N] [--limit N]
# Gateway (verify end to end: list and invoke tools over the live MCP gateway)
c1i mcp gateway list-tools [--full] [--gateway-url <url>]
c1i mcp gateway call <tool-name> [--args '{"k":"v"}'] [--gateway-url <url>]Auth for register / update-credentials: convenience flags cover the simple methods — --auth none, --auth bearer-token --bearer-token TOKEN, --auth custom-header --header-name NAME --header-value VALUE, --auth basic-auth --basic-auth-username USER --basic-auth-password PASS. For OAuth2 / AWS SigV4 / Google service-account auth, pass the full config object via --hosted-config-file / --external-config-file (JSON file, or - for stdin) — generate a ready-to-edit skeleton with --print-config-template --auth <method> [--type hosted] instead of hand-writing it. Secrets are sealed server-side; reads only ever return *_configured booleans, never the values.
mcp tools approve is the standard post-registration step: newly discovered tools (from register or resync-tools) start in PENDING_REVIEW, and an admin approves each one for the gateway to proxy calls. History endpoints return records newest-first.
mcp gateway closes the configure-then-verify loop: after registering a server and approving its tools, list-tools runs the MCP handshake against the live gateway and shows what's actually callable, and call invokes a tool and prints its result. The gateway URL defaults to the -mcp host derived from --url (e.g. acme.conductor.one → acme-mcp.conductor.one/v1); override with --gateway-url. Your standard C1 token is accepted by the gateway, so no extra auth is needed. call always prints the full result, but exits 7 (not 0) when the result itself reports isError: true — the call succeeded, the tool didn't.
c1i requests create grant --app-id <id> --entitlement-id <eid> [--user-id <uid>] [--description <text>] [--duration <duration>] [--emergency]
c1i requests create revoke --app-id <id> --entitlement-id <eid> [--user-id <uid>] [--description <text>]
c1i requests list [--user-id <id> | --all] [--app-id <id>] [--entitlement-id <id>] [--state open|closed] [--type grant|revoke] [--page-size N] [--page-token TOKEN] [--limit N]
c1i requests get <request-id>On create, --user-id defaults to the authenticated user when omitted.
requests list is the requester lens on access requests (the grant/revoke tasks
you file): by default it shows requests you opened or are the subject of —
complementing tasks list, which is the approver's My Work lens. Use --user-id
to scope to another user or --all for every request in the tenant. requests get fetches a single request (the task_id returned by requests create) as
pretty JSON, including its current policy step and outcome.
c1i export events [--since <rfc3339>] [--until <rfc3339>] [--since-event-uid <uid>] [--sort asc|desc] [--page-size N] [--page-token TOKEN] [--limit N]export events dumps the C1 system log — OCSF-formatted audit events — as an
NDJSON stream (one event per line), auto-paginating through the whole result.
Redirect it to a file to archive events or ship them to an external system:
c1i export events > audit.ndjson # everything, oldest first
c1i export events --since 2026-07-01T00:00:00Z --until 2026-07-08T00:00:00Z
c1i export events --since-event-uid <last-uid> # incremental sync--sort defaults to asc (chronological), which pairs with --since-event-uid
for incremental sync. --fields works on events too (e.g. --fields activity_name,actor.user.email_addr,time).
# GET request
c1i api --path /api/v1/apps
# POST request
c1i api --path /api/v1/search/users --body '{"pageSize":10}'
# Other methods — --method takes GET, POST, PUT, PATCH, or DELETE
c1i api --path /api/v1/apps/<app>/connectors/<conn>/mcp_tools/<id> --method DELETE
# DELETE normally refuses a body; some endpoints (e.g. remove-membership)
# require one, so opt in explicitly
c1i api --path /api/v1/apps/<app>/entitlements/<ent>/remove-membership \
--method DELETE --body '{"appUserId":"<app-user>"}' --allow-delete-body
# Read the body from a file, or stdin with "-"
c1i api --path /api/v1/search/users --body-file query.json
echo '{"pageSize":10}' | c1i api --path /api/v1/search/users --body-file -
# Add query params and headers (both repeatable)
c1i api --path /api/v1/apps --query page_size=5 --header X-Request-Id=abc123
# Auto-paginate through all results (NDJSON output, one item per line)
c1i api --path /api/v1/apps --paginate
# Force the array field to drain when auto-detection picks the wrong one
c1i api --path /api/v1/automation_executions --paginate --list-key automationExecutionsThe method defaults to GET, or POST when a body is set; pass --method for
PUT/PATCH/DELETE. The body comes from --body (inline JSON) or --body-file (a
file, or - for stdin) — the two are mutually exclusive. GET and DELETE refuse
a body by default (a body on either is more likely a mistake than intent); pass
--allow-delete-body to lift that for DELETE specifically, for the handful of
C1 endpoints that require one. --query key=value and
--header key=value are both repeatable. When --paginate is used, each page's first array-valued field is unwrapped and each item is emitted as a single line of NDJSON — the same format used by list commands. This covers both the canonical list key and typed keys like automationExecutions; use --list-key <field> to force a specific field. If the server returns the same nextPageToken twice in a row, c1i aborts with an error rather than looping forever. Without --paginate, the full JSON response is pretty-printed.
The docs commands require no C1 credentials — agents can use them to explore the API before authenticating.
# Print the agent bootstrap doc: output contracts, exit codes, when to
# prefer first-class commands over raw API calls (write to a file with --output)
c1i docs agents [--output AGENTS.md]
# Search documentation
c1i docs search "access reviews"
# Fetch a documentation page
c1i docs page product/admin/campaigns
# List API endpoints (filtered)
c1i docs endpoints --filter task
# Show full request/response schema for an endpoint
c1i docs endpoint /api/v1/search/tasks
# Dump the raw OpenAPI spec
c1i docs openapi
# Print an embedded, task-oriented runbook (list names if omitted)
c1i docs guide
c1i docs guide register-mcp-serverdocs guide is embedded static content (no network call), unlike docs search / docs page which hit the C1 documentation site. Guides ship in two families: registering and operating MCP servers (register-mcp-server, assign-toolset-everyone, test-mcp-gateway, delegate-entitlement-provisioning) and everyday app/access-request workflows (configure-new-app, request-access, inspect-and-approve-task). Run c1i docs guide with no argument for the full, current list.
docs skill is kept as an alias of docs agents for backward compatibility;
both print identical output.
- List commands (
users list,apps list, etc.) output NDJSON (one JSON object per line). apioutputs pretty-printed JSON. With--paginate, outputs NDJSON (one list item per line). A200whose body is not JSON is an error (exit6), not a silent pass-through — a--paththat escapes the API prefix can reach the web app and return HTML, which used to print as though it had succeeded. An empty body still succeeds, since some endpoints answer a write with nothing.docscommands output NDJSON (search,endpoints), pretty JSON (endpoint,openapiis YAML), or plain text (page).- List commands auto-paginate by default. Pass
--page-tokento fetch a single page manually. --page-sizecontrols the per-call batch size (max 100). Use--limit Nto cap the total number of results emitted; auto-pagination stops fetching new pages once the cap is reached.
--fields trims every emitted JSON object to just the keys you name — a big
token saver when an agent only needs a couple of fields from a large list.
# Only id and email from each user
c1i users list --fields id,email
# Dot-paths select nested fields; nesting is preserved in the output
c1i api --path /api/v1/apps --paginate --fields id,displayName
c1i functions get <id> --fields id,displayName,publishedCommitId- Comma-separated; use dot-paths (
user.email) for nested access. - Matches the emitted keys, trying an exact match first, then falling back
to a case- and separator-insensitive match. So
--fields displayNameresolves whether the output usesdisplayName(single-object reads) ordisplay_name(list rows); the output keeps the source key's own spelling. - Single-object
getcommands pass through the API response as-is, which wraps the resource under the endpoint's own top-level key (function,app,automation,userView.user, ...). You don't need to know that key: a name that doesn't match at the top level is also searched for inside the wrapper, so--fields id,displayNameonfunctions getfindsfunction.id/function.displayNameautomatically. The full path (--fields function.id) still works too and is tried first. If the same name exists at more than one depth, the shallowest match wins; a tie at the same depth resolves to the alphabetically first full path, deterministically. - A
--fieldsspec that matches nothing at all in the response (a typo, or a field that truly doesn't exist) is a usage error (exit2), not a silent{}. This is a zero-match check only:--fields id,dispalyName(typo) still exits0and silently returns just{"id": ...}— the misspelled field is dropped with no error and no other signal that it didn't match anything. This is deliberate, not a gap in the check:--fields/C1I_FIELDSis a persistent, session-wide setting, so one spec is routinely applied across many differently-shaped responses; erroring on any unmatched name would make a session-wideC1I_FIELDSfail on every command whose response happens to lack one of the names. Double-check the spelling of every name you pass — the tool only catches getting all of them wrong.- On list commands and
api --paginate, "the response" means the whole result, not each row: rows are decided and streamed out one at a time as they're fetched (never buffered — these commands can walk unbounded, multi-page results), but a row whose projection is empty is skipped rather than printed as{}— a field present on some rows but absent on others just means fewer rows are printed, not an error, as long as it matches at least one row anywhere. Only a spec that matches nothing in every row across every page is an error, with a message ("...matched no keys in any row of the response") distinguishable from the single-object case above. Because every row's own projection was already empty (and skipped) by the time that whole-result verdict is known, nothing is printed on stdout before the error — never rely on output length to detect success without checking the exit code. If you're piping tojq(c1i ... | jq ...), remember$?reads the pipe's last command, notc1i's — the stderr message is the durable signal here, not the exit code you'd read from a naive$?after the pipe. - Worst case: a
--fieldsspec that matches nothing at all, paired with--limit, scans the collection to completion before erroring — "nothing ever matched" can't be known short of exhausting every page, and a typo is the ordinary way to hit this. Live-measured:tasks list --fields <typo> --limit 2made 193 requests over ~41s before exiting2on a tenant with ~9,650 tasks; on a 35,000-rowentitlements listthat's minutes. This isn't specific to--fields— it's the same ruleaccounts list --unmapped-onlyandfunctions usagealready follow for their own client-side filtering (a filter applied after the fetch can't bound the work when nothing matches, no matter what--limitsays);--fieldscombined with--limitis one more instance of it, not a new behavior.
- On list commands and
- Missing fields are silently omitted, so requesting a superset is safe.
- Also settable via
C1I_FIELDS. Applies to read output — list commands,api, and single-objectgetcommands. Mutation confirmations (create/update/delete) are never projected, so a session-wideC1I_FIELDScan't hide their status.
On failure, c1i writes an error to stderr and exits with a code an agent can
branch on without parsing text:
| Code | Meaning |
|---|---|
0 |
success |
1 |
generic / unclassified error |
2 |
usage error (bad flags or arguments, an empty id, an id the API redirects to a collection, or the API returned any 4xx other than 401/403/404/408/429/499) |
3 |
not authenticated, or API returned 401/403 |
4 |
API returned 404 (not found) |
5 |
API returned 429 (rate limited — back off and retry) |
6 |
C1 failed: the API returned 5xx, a redirect chain never settled, or it answered 200 with a body that isn't JSON |
7 |
mcp gateway call completed, but the tool itself reported an error (isError: true in its result) |
8 |
a system beyond C1, or the MCP protocol layer, failed — an upstream connector was unreachable or errored, the gateway itself was unreachable (DNS failure, refused connection), or the gateway returned a protocol-level JSON-RPC error |
6 and 8 are both "something remote broke," but they call for different
responses. 6 means C1 itself is failing, so retrying later is reasonable and
there is nothing to fix at your end. 8 means C1 answered fine and something
past it did not, so the same call will usually fail the same way: if a connector
is at fault, inspect it (c1i mcp servers get <connector-id> --app-id <id>, and
mcp servers test-connection for an EXTERNAL server) — it may be unreachable or
its credentials may have expired; if it's a protocol-level JSON-RPC error, that
indicates a version mismatch or a bug in c1i, worth reporting rather than
working around. Before this split both arrived as 6, which made "C1 is down"
indistinguishable from "the Slack connector is down."
An empty id argument is a usage error, not a lookup: c1i policies get ""
exits 2 and sends nothing. Without that guard an empty id renders a trailing
empty path segment, which the API redirects to the collection endpoint — so the
command used to print the entire list and exit 0. The same check applies to a
raw api --path ending in /.
Relatedly, the REST client is selective about HTTP redirects. It follows one
only when both hold: the target path is identical to what was requested (a
trailing-slash difference counts as a change), and the target host is in the same
trust scope as the request host — the same host differing only in scheme or port,
or a label.-prefix relationship in either direction with at least two labels,
which covers apex ↔ www canonicalization. Anything else — a different path, or
the same path on an unrelated host — is refused as an error (exit 2) naming the
target.
Both halves matter. The path rule is what closed a real bug: an id of / or .
escapes to a path the API redirects to the collection, which turned a
single-object read into a full listing with exit 0. The host rule is what keeps
your token safe: a followed redirect is re-authenticated, so an unrestricted
follow would hand your bearer token to whatever host the redirect named.
A chain of allowed redirects that doesn't settle within five hops fails as a
remote error (exit 6) rather than looping.
This applies everywhere the CLI sends HTTP: the REST client, the MCP gateway, and
the login handshake all share the same transport, so the path and redirect
guards, --debug tracing, and --max-retries all cover the gateway and login
too, not just REST commands. A bad id is the only cause of a refused 3xx
observed so far, which is why it maps to exit 2 — a redirect on an otherwise
well-formed request would not be the caller's mistake, and would still report
2.
Pass --error-format json (or C1I_ERROR_FORMAT=json) to get a machine-readable
error object instead of the default Error: <msg> line. For API errors it
includes the status, method, path, and response body:
$ c1i api --path /api/v1/apps/<nonexistent-id> --error-format json
{"body":{"code":5,"message":"not found (request-id: ...)"},"error":"API error: API GET /api/v1/apps/<nonexistent-id> returned 404: ...","method":"GET","path":"/api/v1/apps/<nonexistent-id>","status":404}The body is embedded as JSON when the API returned JSON, otherwise as a string.
c1i requires a C1 URL.
c1i requires https. A URL with any other scheme is rejected (exit 2); it
is not rewritten. Credentials embedded in the URL are dropped with a warning on
stderr, and an embedded password is never echoed.
The scheme may be omitted (tenant.c1eu.ai), in which case https is assumed.
The host is lower-cased, so HTTPS://TENANT.C1EU.AI and tenant.c1eu.ai
resolve identically, and a protocol-relative //tenant.example is handled.
Both *.conductor.one and *.c1eu.ai (EU) tenant domains are accepted — pass
whichever your tenant uses. Only the shape of the URL is checked, never the
domain, so a typo like mycompany.conductor.on is accepted here and surfaces
later as an authentication failure.
A bare name (--url mycompany) is rejected: with more than one tenant domain in
use it is ambiguous, and silently expanding it to mycompany.conductor.one would
point an EU tenant at the wrong region. The error names where the value came
from, which matters when it is a stale entry in ~/.c1i.yaml rather than
something you just typed. A single-label host is fine as long as it arrives as a
URL (https://c1-staging), which is how an internal-resolver name is reached.
If you previously authenticated with a mixed-case
--url, your stored credential was keyed by that exact casing and is no longer found now that the host is normalized. Runc1i auth loginonce to re-store it.
Set it via (in order of precedence):
-
--urlflag -
C1I_URLenvironment variable -
~/.c1i.yamlconfig file:url: https://mycompany.conductor.one
These are equivalent:
--url https://mycompany.conductor.one--url mycompany.conductor.one--url MYCOMPANY.CONDUCTOR.ONE(the host is lower-cased)
Every command prints which tenant it's about to use when the URL came from
~/.c1i.yaml. Nothing on the command line names the config file, so a
stale entry there sends a command to the wrong tenant with no visible sign
otherwise. --url and C1I_URL don't print this warning: both are explicit
choices made for that invocation, and warning on every normal use of
C1I_URL would just train you to stop reading it. The warning goes to
stderr, once per invocation (never once per page of a paginated list):
Warning: no --url flag given; targeting https://mycompany.conductor.one (from ~/.c1i.yaml)
For credential storage, see Credential sources below.
Transient API failures are retried automatically with exponential backoff and
jitter, honoring a Retry-After header when the server sends one. This keeps
long auto-paginated pulls from failing on a single rate-limit blip. What gets
retried depends on the request, to avoid duplicating side effects:
429 Too Many Requests— retried for every command (the request is rejected before the server processes it, so a retry is always safe).- Transient
5xx(500, 502, 503, 504) and network errors — retried only for idempotent reads and updates (GET/PUT/DELETE). Non-idempotentPOSTmutations (e.g.requests create,tasks approve) are not retried on these, since the server may have already applied the change before the failure. Non-transient 5xx (501 Not Implemented, 505, 511) are never retried.
Control the retry budget (attempts after the first try) via, in order of precedence:
--max-retries Nflag (applies to any command)C1I_MAX_RETRIESenvironment variable- Default:
4
Set --max-retries 0 to disable retries entirely. Non-retryable responses
(4xx other than 429, and 501/505) fail immediately.
This covers the token mint too: minting or refreshing the OAuth2 bearer
c1i authenticates with is itself a request, subject to the same 429 retry
(never 5xx/network, since it's a POST) and the same --max-retries budget.
Every request also gets a fixed timeout, per attempt (a retried request
gets a fresh budget, not a shrinking share of one deadline): 10 minutes
for a REST or MCP gateway request, 30 seconds for auth login's
device-flow requests and the OAuth2 token mint/refresh above — tighter
because those are fast request/response exchanges, not the kind of call
that legitimately runs long. Neither is configurable. 10 minutes leaves
roughly 3x headroom over the longest request this CLI is known to make
(an MCP tools/call invoking a slow tool, observed at 182 seconds), so
there's no known case that needs a longer one.
--dry-run (or C1I_DRY_RUN=1) previews a mutating request — its method, path,
and pretty-printed JSON body — and returns without sending it:
$ c1i requests create grant --app-id A1 --entitlement-id E1 --user-id U1 --dry-run
[dry-run] POST /api/v1/task/grant
{
"appEntitlementId": "E1",
"appId": "A1",
"identityUserId": "U1"
}It applies to every write command (requests create, tasks approve/deny/comment,
accounts set-owner, the mcp mutations) and to non-GET api calls, and never
sends the mutation itself. Most previews run fully offline — no credentials
required. The exceptions are tasks approve/deny (authenticate and read the
task to resolve its current policy step) and requests create grant/revoke
when --user-id is omitted (authenticate to resolve it to the caller) — both so
the previewed body is exact.
--debug (or C1I_DEBUG=1) traces each HTTP request to stderr — method, URL,
response status, and elapsed time, including every retry attempt. Headers and
bodies are never logged, so credentials don't leak. Output goes to stderr, so it
won't corrupt piped JSON on stdout:
$ c1i apps list --debug 2>trace.log
$ cat trace.log
> GET https://mycompany.conductor.one/api/v1/apps
< GET /api/v1/apps 200 OK (142ms)# Browser-based login (OAuth device flow)
c1i auth login
# Or store credentials directly
c1i auth login --client-id <id> --client-secret <secret>
# Check credential status (also reports the storage backend)
c1i auth status
# Show the authenticated principal: user ID, display name, email, role/permission/feature counts
c1i auth whoami # add --verbose for full roles/permissions/features arrays
# Remove stored credentials
c1i auth logoutc1i reads credentials from the first source that has them, in this order:
- Environment variables — set
C1I_CLIENT_IDandC1I_CLIENT_SECRET(alongsideC1I_URL) for non-interactive / CI use. Both must be set; if only one is set the value is ignored. - OS keyring — Keychain on macOS, Credential Manager on Windows, Secret Service (e.g. gnome-keyring, KeePassXC) on Linux. Used by default when available.
- File fallback — a
0600JSON file under your config directory (~/.config/c1i/credentials/on Linux,~/Library/Application Support/c1i/credentials/on macOS,%AppData%\c1i\credentials\on Windows). Used automatically when no OS keyring is available — typical on headless Linux servers, containers, CI runners, and WSL without a desktop environment.
c1i auth login writes to the OS keyring when it can and falls back to the
file backend transparently. c1i auth status tells you which source served
the active credentials.
# bash
c1i completion bash > /etc/bash_completion.d/c1i
# zsh
c1i completion zsh > "${fpath[1]}/_c1i"
# fish
c1i completion fish > ~/.config/fish/completions/c1i.fishc1i version # or: c1i --versionApache 2.0