Skip to content

Claude Gateway — CLI Reference

Auto-generated from the route manifest (scripts/gen-cli.ts). Do not edit by hand. See the HTTP API reference for integration protocols.

The claude-gateway binary accepts friendly <noun> <verb> subcommands.

Starting the gateway is explicit. claude-gateway gateway start runs the server in the foreground; every other invocation — no arguments, --help, or a typo — prints help or an error and exits, so exploring the CLI can never leave a stray server on the gateway port.

Upgrading from < 1.8: a service unit whose ExecStart runs the binary with no command still starts the server, with a deprecation warning. Change it to claude-gateway gateway start (or reinstall the unit with claude-gateway service install); a future release will drop the shim.

Global flags

FlagMeaning
--url <url>Gateway base URL (else $CLAUDE_GATEWAY_URL, else http://<bind>:<port> when a gateway is running on this host, else config.gateway.publicUrl)
--key <key>API key (else $CLAUDE_GATEWAY_API_KEY, else the first admin key in config)
--jsonPrint the raw JSON response only (stdout reserved for JSON)
--data <json>JSON object merged into the request body (write commands)
--helpShow help for the command

Which address the CLI uses. config.gateway.publicUrl describes this gateway as seen from outside, so on the gateway's own host both addresses are the same server — the public one just adds a reverse-proxy hop. Routing through it makes every command depend on that proxy, and a proxy that enforces its own authentication (which the CLI has no credentials for) answers 401 to commands that work over loopback. So when a gateway is running on this host (detected from the pidfile it writes on boot, a file read and a signal-0 — no subprocess), the local bind wins over publicUrl. --url and $CLAUDE_GATEWAY_URL still override, for deliberately exercising the proxy path or reaching another host.

The port comes from that same pidfile, whose second line is the port the gateway actually bound. $PORT describes the shell running the CLI, not the shell the server was started from: after PORT=9000 make start in one terminal, a plain CLI in another would otherwise address port 10850, where nothing listens. $PORT is still the fallback when no gateway is running, or when the pidfile predates the port line.

A pidfile can also outlive its gateway — killed without cleanup, and the pid reissued to some unrelated process. The local address is still preferred, but publicUrl names the same gateway, so a local address that cannot be reached at all is retried once there, with a line on stderr saying so. An address that answered — including with an error — is never retried: the gateway was reached, and asking somewhere else would only hide its answer.

agents and channels settle the address once, at the start of the session, rather than per request: they thread one base URL through an interactive flow, so a mid-wizard switch would be worse than a single decision up front. The rule for choosing is the same one described above.

gateway status and service install go further: they report on the gateway process on this host, so they resolve --urlhttp://<bind>:<port> and ignore $CLAUDE_GATEWAY_URL and publicUrl entirely — a proxy still answering from a different instance would otherwise report a dead local service as healthy.

doctor probes the address the CLI will use (that check decides its exit code) and adds an informational probe of the other address whenever the two differ, marked [--]. A public URL that rejects an unauthenticated /health is reported, but never fails doctor — the CLI is not using that address. It also checks gateway.publicUrl itself (marked [warn] when unset — a capability gap, not a misconfiguration — and [!!] when set but nothing answers), since that value backs generate_image reference edits, share_file, and /cli, and nothing else surfaces its absence (#472). Skipped when --url/$CLAUDE_GATEWAY_URL point at a different host — it is this host's own config file, so it is not context for a question about another machine.

Colour. Help and diagnostic text is coloured only when the stream it is going to is a terminal — stdout for a requested help listing, stderr for everything else — so piping either one strips the escapes. Set NO_COLOR to turn colour off, or FORCE_COLOR=1 to keep it through a pipe; NO_COLOR wins if both are set. Result output is never coloured at all: printResult does not touch the palette, so --json piped into jq is unaffected by any of this.

Lifecycle & diagnostics (do not require a running server)

CommandDescription
claude-gateway gateway startRun the gateway in the foreground (the only command that boots it)
claude-gateway gateway statusShow the owning manager and /health on the local bind address
claude-gateway gateway restartRestart via the owning manager (systemd-user/systemd-system/pm2/foreground)
claude-gateway gateway stopStop the gateway
claude-gateway gateway logs [--follow] [--lines <n>] [--agent <id>] [--json]Read the gateway log files directly (no running server needed)
claude-gateway doctorCheck config, key resolution, owning manager, and connectivity
claude-gateway debug-bundleWrite a small redacted diagnostics bundle for a stuck session
claude-gateway api <METHOD> <path>Escape hatch: call any endpoint directly

A bare claude-gateway gateway (or crons, service, …) prints its verbs and exits 1 — you forgot the verb. The same listing with --help exits 0, and -h is accepted wherever --help is. --help never has a side effect: debug-bundle --help prints usage rather than writing a bundle.

Where help goes. A listing you asked for (--help, or a bare claude-gateway) is the command's result and goes to stdout, so claude-gateway --help | less and claude-gateway crons --help | grep create work. The same listing printed because the invocation was wrong goes to stderr with a non-zero exit, leaving stdout carrying results only. Single-dash tokens other than -h/-V are values, not flags, so a -5 argument is never mistaken for one.

Running as a service

CommandDescription
claude-gateway service install [--manager systemd|pm2] [--scope user|system] [--run-as <user>] [--after <target,...>] [--env-file <path>] [--env KEY=VALUE,...] [--config <path>] [--yes] [--print] [--force]Generate and start a service
claude-gateway service status [--manager systemd|pm2] [--scope user|system]Report installed/enabled/active state as JSON
claude-gateway service uninstall [--manager systemd|pm2] [--scope user|system] [--yes]Stop and remove the service
claude-gateway service start [--manager systemd|pm2] [--scope user|system]Start the installed service (found even when inactive)
claude-gateway service stop [--manager systemd|pm2] [--scope user|system]Stop the installed service
claude-gateway service restart [--manager systemd|pm2] [--scope user|system]Restart the installed service (starts it if it was stopped)
  • systemd (the default) installs a user unit at ~/.config/systemd/user/claude-gateway.service — no sudo, and it runs as the user that owns ~/.claude-gateway. Run loginctl enable-linger <user> once if it must survive logout.
  • --scope system (systemd only) installs a root-owned unit at /etc/systemd/system/claude-gateway.service instead, for automated/infra provisioning that needs a fixed system account. Requires already running as root (never escalates via sudo) and --run-as <user>, which becomes the unit's User=; WantedBy= is multi-user.target instead of default.target, and the loginctl hint is skipped. It never triggers the system-scope conflict check below against itself.
  • --after <target,...>, --env-file <path>, and --env KEY=VALUE,... customize the generated unit (either scope): extra After= ordering targets, an EnvironmentFile=-<path> for secrets that never appear in the unit text, and additional non-secret Environment= lines. --env refuses to override HOME, PATH, or GATEWAY_CONFIG.
  • Every path in the generated unit (node, entry point, config, working directory) is absolute, and ExecStart always uses the explicit gateway start command. No secrets are written into the unit — the gateway reads ~/.claude-gateway/.env itself.
  • --print shows exactly what would be installed and exits without touching anything. Install and uninstall both ask for confirmation unless --yes is given (uninstall stops a running gateway), and refuse to run non-interactively without it.
  • install verifies /health on the local bind address, and uninstall reports the state the manager actually reports afterwards — never the state that was intended.
  • Exit codes for install, start and restart (every command that is supposed to leave a running gateway behind): 0 fully healthy, 1 the action itself failed — install/enable, or the start/restart — or a validation/confirmation gate refused, 2 the action succeeded but /health never answered within the poll window. Distinguishing 1 from 2 by exit code alone means a caller doesn't have to parse the JSON result on stdout just to tell "didn't happen" apart from "happened, health unconfirmed". status, stop and uninstall never return 2; they are not trying to produce a service that answers.
  • (systemd, user-scope installs) install refuses by default if a claude-gateway.service unit already exists and is enabled or active at system scope (e.g. from provisioning outside this CLI) — it prints the exact sudo systemctl disable --now claude-gateway.service to resolve it; pass --force to install anyway.
  • Re-running install against an already-active unit whose rendered content changed restarts it automatically; unchanged content leaves the running unit alone.
  • After installing, gateway restart/stop detect and drive that same service.
  • service start\|stop\|restart act on the unit selected by --manager/--scope, the same way status/uninstall do — discovered from disk, so they find an installed-but-inactive service too. This is different from gateway restart/stop, which only drive whatever manager is currently reported active, and can never start a stopped service. stop on an already-stopped (or never installed) service is a no-op success, matching uninstall's idempotence; start/restart on a service that was never installed is an error telling you to run service install first.
  • start on a service that is already running still checks /health and still reports it, so the result is the same shape either way and 2 still means "running, but answering nothing" — a process manager calling a wedged gateway active is precisely the case a health check is for.

App Store (Docker-compose apps)

CommandDescription
claude-gateway app listList installed apps and their status
claude-gateway app start <name>Start a stopped app
claude-gateway app stop <name>Stop a running app
claude-gateway app restart <name>Restart an app
claude-gateway app uninstall <name> [--yes]Remove an app's containers and installed files (keeps backups)
claude-gateway app install <source> [--version <v>] [--commit <sha>] [--env KEY=VALUE,...] [--env-file <path>] [--ports NAME=PORT,...] [--wait]Install an app

A thin client over /v1/apps (see the Apps API for the full HTTP reference) — every action is the same admin-gated call the dashboard's App Store UI makes, so there is only one authorization/behavior path to keep correct.

<source> is classified by shape, so there is no separate --registry-app/--github-url/ --local-path flag for the common case: an http(s):// URL is a GitHub source, a path starting with /, ./, ../, or ~ is a local (symlinked, dev-mode) source, and anything else is a registry app name. --version only applies to a registry source; --commit only to a GitHub source (both optional — a GitHub install with no --commit resolves HEAD).

--env and --env-file both fill the install's env_vars, and are merged (--env wins on a conflict). Prefer --env-file for secrets: a value passed on the command line is readable by every local user in /proc/<pid>/cmdline for as long as the install runs, and is written verbatim to the caller's shell history. The file is ordinary dotenv text — KEY=VALUE lines, # comments, and surrounding quotes stripped — parsed exactly like the gateway's own .env.

install is asynchronous: the server returns a jobId immediately and this command never reports "installed" on its own — only that the job was accepted. Poll it with claude-gateway api GET /v1/apps/jobs/<jobId>, or pass --wait to have this command poll here and print the real outcome (streaming the job's log lines to stderr as they arrive).

uninstall asks for confirmation unless --yes is given, and refuses to run non-interactively without it — same convention as service install/uninstall. It removes the app's containers and installed files, but never its backups.

Versions & updates

CommandDescription
claude-gateway versionPrint the installed gateway version
claude-gateway update checkRead-only: installed vs. published gateway version
claude-gateway update [--yes]Show current → target, confirm, then npm install -g the latest
claude-gateway claude versionPrint the installed Claude Code version
claude-gateway claude update checkRead-only version check for Claude Code
claude-gateway claude update [--yes]Update Claude Code through its own native updater

Both use the same detection and install strategy as the dashboard's Update button (src/packages/registry.ts). Claude Code is updated by its native updater, never by npm install -g — that would install a second copy that isn't the binary on PATH. Updating the gateway replaces the files on disk; the running process keeps serving the previous build until claude-gateway gateway restart.

Agents & channels (require a running server)

CommandDescription
claude-gateway agents listList agents accessible by this key
claude-gateway agents create [id] [--description <v>]Interactive wizard — generate workspace files, confirm, optionally connect Telegram/Discord
claude-gateway agents update [--agent <id>]Regenerate AGENTS.md, or connect/update/disconnect Telegram/Discord/LINE/Slack
claude-gateway channels pending --agent <id> [--channel telegram|discord]List incoming pairing requests
claude-gateway channels approve --agent <id> --channel <v> --code <v>Approve a pending pairing request
claude-gateway channels deny --agent <id> --channel <v> --code <v>Deny and remove a pending pairing request

Resource commands

crons

CommandMethodPathAuthDescription
claude-gateway crons create --agentId <v> --name <v> [--type <v>] [--schedule <v>] [--scheduleKind <v>] [--scheduleAt <v>] [--command <v>] [--prompt <v>]POST/v1/cronskeyCreate a cron job
claude-gateway crons delete <id>DELETE/v1/crons/:idkeyDelete a cron job
claude-gateway crons get <id>GET/v1/crons/:idkeyGet a single cron job
claude-gateway crons list [--agent <v>]GET/v1/cronskeyList cron jobs accessible by this key
claude-gateway crons run <id>POST/v1/crons/:id/runkeyTrigger a cron job now
claude-gateway crons runs <id> [--limit <v>]GET/v1/crons/:id/runskeyGet cron job run history
claude-gateway crons statusGET/v1/crons/statuskeyCron scheduler status
claude-gateway crons update <id> [--name <v>] [--type <v>] [--schedule <v>] [--scheduleKind <v>] [--scheduleAt <v>] [--timezone <v>] [--command <v>] [--prompt <v>] [--telegram <v>] [--discord <v>] [--timeoutMs <v>] [--deleteAfterRun] [--enabled]PUT/v1/crons/:idkeyUpdate a cron job

Claude Code inside.