Skip to main content

An admin CLI for your app, without building one

Evan Phoenix

Every running app accumulates maintenance tasks: Delete this user, clear that cache, rerun a stuck job, flip a feature flag, etc. None of it belongs on a public route, and most of it doesn’t justify a UI.

The usual answers are SSH into a box and run a script, or build an admin dashboard nobody wants to maintain, or hand-edit a row in production and tell nobody.

Today we’re shipping the admin API: a way to expose maintenance methods from your app and call them from miren admin, with auth handled by the platform.

How it works

Your app exposes a JSON-RPC endpoint at a well-known path. Miren generates an admin token, injects it into your app as ADMIN_TOKEN, and proxies authenticated CLI calls to that endpoint over the internal network. No public exposure, no extra DNS, no separate dashboard service.

From your laptop:

miren admin delete-user user_id=user-123

That becomes a JSON-RPC 2.0 request to /.well-known/miren/admin on your app, signed with the admin token, routed over Miren’s internal network. Your app handles the method and returns a result. The CLI prints it.

Implementing it

Three requirements:

  1. Listen on POST /.well-known/miren/admin.
  2. Validate Authorization: Bearer $ADMIN_TOKEN on every request.
  3. Speak JSON-RPC 2.0.

There’s no Miren SDK to vendor and no protobuf to compile. The whole protocol is a handful of JSON documents over HTTP: a request names a method and carries params, a response returns a result or an error. One optional method, $methods, lets your app advertise what it exposes — that metadata is what drives the CLI’s discovery, --list output, and parameter validation. Skip it and regular calls still work; you just lose discovery.

The exact request, response, and error shapes, the error code conventions, and the introspection format are all in the admin interface docs. End-to-end, an app handling auth, dispatch, and $methods is around 40 lines in any language. The docs have a Python example with the full structure, plus a Go helper (miren.dev/jsonrpc3) if you’d rather skip the dispatch boilerplate.

The CLI side

List what’s available:

$ miren admin --list -a myapp
Admin methods for myapp

  clear-cache    Clear the application cache (maintenance)
  delete-user    Delete a user by ID (users)
                   user_id  string  (required)
  list-users     List all users in the system (users)
                   limit    number
                   offset   number

Usage: miren admin -a myapp <method> [key=value ...]

That list comes from the metadata your app registered. The CLI also uses it to validate parameter names and types before sending the request, so a typo in user_id is a local error rather than a round-trip plus a -32602.

Call a method with key=value pairs:

$ miren admin delete-user user_id=user-123
OK

$ miren admin list-users limit=2
Users
┌─────────┬─────────┬───────────────────┐
 ID NAME EMAIL
├─────────┼─────────┼───────────────────┤
 user-1 Alice alice@example.com
 user-2 Bob bob@example.com
└─────────┴─────────┴───────────────────┘

In a TTY, the CLI renders the response as a table when the data looks like a table and as a key/value list when it doesn’t. Piped or redirected, output switches to JSON automatically:

miren admin get-stats --json | jq '.users'

--json and --pretty override the detection. --params-file (or -f -) reads params as a JSON blob from a file or stdin, for when key=value gets unwieldy.

Why a method registry, not just an endpoint

The simpler design — “expose any HTTP endpoint, we’ll proxy it” — pushes every problem onto the app. No discovery, no shared CLI ergonomics, no shared validation. Every team writes a small curl wrapper and calls it good enough.

JSON-RPC plus introspection keeps the app as the source of truth for which methods exist and what they take, while letting the CLI render, validate, and route calls without knowing anything specific to one app. miren admin delete-user user_id=… looks the same whether the method was registered yesterday or two years ago, in Go or Python or anything else.

How we use it

Miren Cloud is itself a Go service deployed on a Miren server and it exposes its admin surface this way. The intro listed “rerun a stuck job, flip a feature flag” as the kind of thing that never justifies a UI — those are literally two of our methods.

Background jobs land in a dead-letter queue when they exhaust retries. Instead of a queue dashboard, we have a handful of bgtask.* methods:

miren admin -a cloud bgtask.deadJobs limit=20
miren admin -a cloud bgtask.retryJob jobId=job-8f21

Feature flags are the same shape. We gate labs features per-organization, and toggling one for a specific org is a method, not an internal admin page:

miren admin -a cloud labs.setOrgFlag org_xid=org-abc name=newdashboard enabled=true
miren admin -a cloud labs.list

The pattern holds across the rest of the backend: registering an edge POP server (pop.register), uploading a TLS cert (pop.cert.add), marking an org as internal so it drops out of our metrics (metrics.markOrgInternal). Each one started as “I need to do this in production exactly once a month” — the kind of task that becomes a risky manual database edit or a one-off script nobody else can find. As methods, they’re discoverable (miren admin -a cloud --list), validated, and gated behind the same platform auth as every other call. Method names can be namespaced with ., which is how we keep ~30 methods across the backend legible.

Try it

Deploy an app that exposes the endpoint, then:

miren admin --list
miren admin clear-cache
miren admin list-users limit=10

The full reference — Python example, error code conventions, introspection protocol — is in the admin interface docs.