mirrorball
DocsReference

JSON output

The {ok, data, meta} envelope, the response shape of every one-shot command, and the full NDJSON event catalogue mirrorball streams while a tunnel is up.

mirrorball (mirb) emits two different kinds of machine output, and which one you get depends on the command, not on a flag:

  • An envelope. One JSON document, written once, when the command is done. This is what mirb ls, mirb stop, mirb logs --format … and mirb --background produce.
  • An NDJSON event stream. One compact JSON object per line, written the instant the thing it describes happens. This is what a foreground mirb up produces, for as long as the tunnel is alive.

A foreground session is a process that runs for hours; a report delivered at exit would be useless to anything waiting for the tunnel to come up. A ls is a question with one answer. The two shapes exist because those are two different problems.

When mirrorball switches to machine output

Three signals, any of which is enough:

SignalWhere it comes from
--jsonExplicit. Available on up, ls and stop.
--format json|yaml|md|toonExplicit. A global flag bunli owns; every command accepts it.
stdout is not a TTYImplicit. mirb web 3000 | jq needs no flag.

The last one is the point of the design: piping mirrorball into anything gives you parseable output without anybody having to remember a flag.

--format selects the serialisation of the envelope only. The event stream is always JSON, one object per line — --format yaml on a foreground mirb up still yields NDJSON, because a streaming YAML document is not a thing a consumer can read incrementally.

# The envelope, as YAML.
mirb ls --format yaml

# Still NDJSON, despite the flag.
mirb example.test 3000 --format yaml

The envelope

{
  "ok": true,
  "data": { },
  "meta": { "command": "ls", "durationMs": 1 }
}
FieldTypeNotes
okbooleantrue on every envelope mirrorball writes. Failures do not produce one — see Errors.
dataobjectThe command's payload. Shape is per-command, documented below.
meta.commandstringup, ls, stop or logs. Never the alias you typed.
meta.durationMsnumberWall-clock milliseconds. Present on every command mirrorball emits today.

undefined fields are absent, not null — that is JSON.stringify's behaviour and mirrorball does not fight it. A session with no --name has no name key at all.

Command responses

mirb --background

Written only after the supervisor has reported a working tunnel, so the ports in it are already listening by the time you read them.

mirb example.test 45241:3000 --background --name web --json
{
  "ok": true,
  "data": {
    "id": "mb_foskwpi71k9jt",
    "name": "web",
    "pid": 15478,
    "status": "ready",
    "target": "example.test",
    "forwards": [
      {
        "localPort": 45241,
        "bindAddress": "127.0.0.1",
        "remoteHost": "localhost",
        "remotePort": 3000,
        "source": "45241:3000",
        "status": "ready"
      }
    ],
    "logFile": "/Users/you/.local/state/mirb/logs/mb_foskwpi71k9jt.log"
  },
  "meta": { "command": "up", "durationMs": 1017 }
}
FieldTypeNotes
idstringmb_ + 13 lowercase alphanumerics. Any unambiguous prefix works as an argument to stop and logs.
namestring?From --name or the profile. Absent when unset.
pidnumberThe supervisor's pid, not ssh's. This is what mirb stop signals.
statusstringready or degraded; the parent does not print until it sees one of those two.
targetstringRendered, e.g. deploy@example.test:2222 — not the Target object.
forwardsarraySee Forward objects.
logFilestringAbsolute path. The same file mirb logs reads.

degraded means the tunnel is up and at least one forward is refused or failed. It is still a successful start, and it still exits 0 — read forwards[].status to decide whether that matters to you.

mirb ls

mirb ls --json
{
  "ok": true,
  "data": {
    "sessions": [
      {
        "id": "mb_foskwpi71k9jt",
        "name": "web",
        "pid": 15478,
        "status": "ready",
        "target": "example.test",
        "forwards": [
          {
            "localPort": 45241,
            "bindAddress": "127.0.0.1",
            "remoteHost": "localhost",
            "remotePort": 3000,
            "source": "45241:3000",
            "status": "ready"
          }
        ],
        "startedAt": "2026-08-19T08:42:20.048Z",
        "reconnects": 0,
        "logFile": "/Users/you/.local/state/mirb/logs/mb_foskwpi71k9jt.log",
        "sshArgv": [
          "-N", "-T",
          "-o", "ExitOnForwardFailure=yes",
          "-o", "ServerAliveInterval=15",
          "-o", "ServerAliveCountMax=3",
          "-o", "ConnectTimeout=10",
          "-o", "BatchMode=yes",
          "-L", "127.0.0.1:45241:localhost:3000",
          "example.test"
        ]
      }
    ],
    "pruned": []
  },
  "meta": { "command": "ls", "durationMs": 1 }
}
FieldTypeNotes
sessions[].startedAtstringISO-8601, UTC.
sessions[].reconnectsnumberHow many times the tunnel has been rebuilt under this id.
sessions[].sshArgvstring[]The exact argv handed to ssh, minus the binary. Verbose, but it is the only thing that answers "what did mirrorball actually run?" without opening a file.
prunedstring[]Ids of records removed on this run because their supervisor was gone.

Pruning happens on every mirb ls, not only under --prune: a record whose supervisor has died describes a tunnel that no longer exists, and listing it would send a caller to a port nothing is on. --prune only changes whether the human-readable mode mentions it; pruned is always populated in the envelope.

mirb stop

mirb stop fosk --json
{
  "ok": true,
  "data": {
    "stopped": [
      {
        "id": "mb_foskwpi71k9jt",
        "name": "web",
        "target": "example.test",
        "outcome": "stopped"
      }
    ]
  },
  "meta": { "command": "stop", "durationMs": 28 }
}
outcomeMeaning
stoppedSIGTERM was enough; the supervisor tore its tunnel down and exited.
killedSIGTERM was ignored for 3s, so SIGKILL followed. The record is removed either way.
already-goneThe process was not alive. Only the record was removed.

mirb stop --all with nothing running is not an error: stopped is [] and the exit code is 0. Naming a session that does not exist is an error — see exit codes.

mirb logs

mirb logs is the one command where a pipe does not switch to JSON. The log is plain text, one record per line, and mirb logs web | grep reconnect wants exactly those bytes. Only an explicit --format — where the caller has said out loud that they want to parse it — produces an envelope.

mirb logs fosk --format json --lines 3
{
  "ok": true,
  "data": {
    "id": "mb_foskwpi71k9jt",
    "logFile": "/Users/you/.local/state/mirb/logs/mb_foskwpi71k9jt.log",
    "lines": [
      "2026-08-19T08:42:20.863Z mirb: localhost:45241 <- localhost:3000 ready",
      "2026-08-19T08:42:20.864Z mirb: session connecting (1 ready)",
      "2026-08-19T08:42:20.864Z mirb: session ready (1 ready)"
    ]
  },
  "meta": { "command": "logs", "durationMs": 1 }
}

lines has no trailing empty entry: the file ends in a newline, and --lines 1 returning [""] would look like an empty log. --follow is ignored under --format; there is no way to append to a document that has already been closed.

Forward objects

forwards[] entries are the same shape everywhere they appear — in the envelope, in a session record on disk, and (minus status) in the session.start event.

FieldTypeNotes
localPortnumberThe port actually bound. Under --auto-port this may differ from what you asked for.
bindAddressstring127.0.0.1 unless --bind/--expose said otherwise.
remoteHoststringHost the remote sshd connects onward to, from its point of view. localhost for the common case.
remotePortnumberPort on remoteHost.
sourcestringThe argument you typed that produced this forward. Preserved verbatim so --auto-port can show what you asked for next to what you got.
statusstringpending, bound, ready, refused or failed.
detailstring?Present on refused and failed. One sentence explaining which.

The three-state readiness model is the reason bound and ready are different words: bound means the local socket accepts connections, ready means a probe reached the service at the far end. With --no-probe, ready is never claimed and forwards stop at bound.

The NDJSON event stream

Emitted by a foreground mirb up in machine mode. One JSON object per line, on stdout, flushed synchronously — a consumer running mirb example.test 3000 | jq -c reacts to forward.ready as it happens, which only works if the write actually reaches the pipe.

Background sessions do not produce NDJSON. The detached supervisor writes human-readable text to its log file instead; mirb --background gives you an envelope and returns.

Every event carries event and ts. ts is ISO-8601 UTC, stamped by the emitter, so the order on the wire is the order things happened.

session.start

The tunnel is being attempted. Always the first line of a session, and re-emitted under the same id on every reconnect.

FieldTypeNotes
idstringStable across reconnects.
targetobject{host, raw} plus user and port when they were given.
forwardsarrayPost-preflight, so these are the ports mirrorball will really use — localPort already reflects any --auto-port shift, while source still shows what you typed.
{"event":"session.start","ts":"2026-08-19T08:46:09.950Z","id":"mb_7s3wh8qxv3kni","target":{"host":"example.test","raw":"deploy@example.test:2222","user":"deploy","port":2222},"forwards":[{"localPort":45292,"bindAddress":"127.0.0.1","remoteHost":"localhost","remotePort":80,"source":"45291:80"}]}

forward.bound

The local socket is accepting connections. Not a claim about the remote service.

FieldTypeNotes
localPortnumberIdentifies the forward. Local ports are unique within a session, which is what makes them usable as a key.
{"event":"forward.bound","ts":"2026-08-19T08:41:46.695Z","localPort":45231}

forward.ready

A probe opened a connection through the tunnel and the service at the far end kept it open. This is the event to wait on before pointing a client at the port. Never emitted under --no-probe.

FieldTypeNotes
localPortnumberIdentifies the forward.
{"event":"forward.ready","ts":"2026-08-19T08:41:47.448Z","localPort":45231}

forward.error

One forward is unusable. Not fatal — the session stays up and the other forwards keep working. A session with any of these ends up degraded rather than failed.

FieldTypeNotes
localPortnumberIdentifies the forward.
codestringA MirbErrorCode. In practice REMOTE_REFUSED (the probe was refused) or SSH_CONNECT (the local port never started accepting).
messagestringThe same sentence that lands in forwards[].detail.
{"event":"forward.error","ts":"2026-08-19T08:41:56.411Z","localPort":45232,"code":"REMOTE_REFUSED","message":"nothing is listening on localhost:5432 at the far end"}

session.ready

Every forward has been classified. This is not a promise that they all worked — read ready against total.

FieldTypeNotes
idstringThe session.
readynumberForwards that are ready, plus those that are bound when probing is off.
totalnumberForwards in the session.

ready: 0 is a legitimate value: the tunnel is up and nothing is listening at the far end.

{"event":"session.ready","ts":"2026-08-19T08:41:47.449Z","id":"mb_l5gggff5pmrz7","ready":1,"total":1}

session.reconnecting

ssh exited for a reason worth retrying, and mirrorball is waiting out a backoff before trying again. Followed by another session.start under the same id.

FieldTypeNotes
attemptnumber1-based.
delayMsnumberThe wait, exponential with jitter.
{"event":"session.reconnecting","ts":"2026-08-19T08:42:05.843Z","attempt":1,"delayMs":1062}

session.exit

The ssh process is gone. On a retryable failure this is followed by session.reconnecting; otherwise it is the last line.

FieldTypeNotes
idstringThe session.
codenumberssh's exit status when ssh ran, otherwise mirrorball's own exit code for the failure that stopped it from running. 143 is the usual value for a stop, being 128 + SIGTERM.
reasonstringstopped for a requested stop, otherwise the classified failure.
{"event":"session.exit","ts":"2026-08-19T08:41:47.867Z","id":"mb_l5gggff5pmrz7","code":255,"reason":"Timeout, server fake.invalid not responding."}

code here is ssh's, not mirrorball's. The process exit code is a separate, much smaller vocabulary — see exit codes.

A complete stream

One forward, one reconnect, then Ctrl-C:

{"event":"session.start","ts":"2026-08-19T08:42:05.213Z","id":"mb_1ylyydp5on0ad","target":{"host":"example.test","raw":"example.test"},"forwards":[{"localPort":45233,"bindAddress":"127.0.0.1","remoteHost":"localhost","remotePort":80,"source":"45233:80"}]}
{"event":"forward.bound","ts":"2026-08-19T08:42:05.269Z","localPort":45233}
{"event":"forward.error","ts":"2026-08-19T08:42:05.842Z","localPort":45233,"code":"REMOTE_REFUSED","message":"nothing is listening on localhost:80 at the far end"}
{"event":"session.ready","ts":"2026-08-19T08:42:05.842Z","id":"mb_1ylyydp5on0ad","ready":0,"total":1}
{"event":"session.exit","ts":"2026-08-19T08:42:05.843Z","id":"mb_1ylyydp5on0ad","code":255,"reason":"Timeout, server fake.invalid not responding."}
{"event":"session.reconnecting","ts":"2026-08-19T08:42:05.843Z","attempt":1,"delayMs":1062}
{"event":"session.start","ts":"2026-08-19T08:42:06.907Z","id":"mb_1ylyydp5on0ad","target":{"host":"example.test","raw":"example.test"},"forwards":[{"localPort":45233,"bindAddress":"127.0.0.1","remoteHost":"localhost","remotePort":80,"source":"45233:80"}]}
{"event":"forward.bound","ts":"2026-08-19T08:42:06.962Z","localPort":45233}
{"event":"forward.ready","ts":"2026-08-19T08:42:07.713Z","localPort":45233}
{"event":"session.ready","ts":"2026-08-19T08:42:07.713Z","id":"mb_1ylyydp5on0ad","ready":1,"total":1}
{"event":"session.exit","ts":"2026-08-19T08:42:11.057Z","id":"mb_1ylyydp5on0ad","code":143,"reason":"stopped"}

Consuming it

# React to readiness as it happens.
mirb example.test 3000 --json | jq -c 'select(.event == "forward.ready")'

The stream is for watching a session you are keeping alive. If what you actually want is "wait until the tunnel works, then do something", use --background: it does not print until the forwards are proven, and it exits 0 when they are.

mirb example.test 5432 --background --json > session.json
psql -h localhost -p 5432 -U postgres
mirb stop "$(jq -r .data.id session.json)"

A consumer that stops reading — | head -1, a jq that exits early — does not crash mirrorball. The closed pipe (EPIPE) makes the event stream go quiet and the tunnel keeps working, which is why the process does not die on the one way people casually inspect a stream.

Errors

A failure never produces an ok: false envelope on stdout. mirrorball writes the error to stderr, in the same two-line form a human gets, and exits with a code that names the class of failure:

mirb: error: could not bind port 3000: already in use
mirb: hint: Something else is listening there. Free it, pick another local port, or pass --auto-port.

Stdout stays clean, so a consumer's JSON.parse never has to survive a sentence that was meant for a person. Branch on the exit code, and read stderr for the explanation. The full mapping is in exit codes.

The one exception belongs to bunli, not to mirrorball: an option that fails validation before the handler runs produces a structured error document, also on stderr, and exits 1.

mirb example.test 3000 --timeout abc
{
  "ok": false,
  "error": {
    "kind": "validation",
    "name": "BunliValidationError",
    "tag": "BunliValidationError",
    "message": "Invalid option 'timeout': Invalid input: expected number, received NaN",
    "command": "up",
    "option": "timeout",
    "expectedType": "default"
  }
}

The stdout / stderr contract

The rule mirrorball holds to, without exception:

  • When mirrorball is in machine mode, stdout carries machine output and nothing else. The envelope, or the NDJSON stream. No progress, no warnings, no hints, no errors.
  • Everything written for a person goes to stderr. Progress lines, the exposure warning, error: and hint:.

The invariant that pays for itself is mirb example.test 3000 > out.txt: progress still appears on your terminal, and out.txt still parses.

In human mode — a TTY, no --json, no --format — the results of one-shot commands do go to stdout (the ls table, the stop lines, raw logs output), and so does the live display, because at that point stdout is a terminal and there is nobody to confuse. Progress and errors are on stderr either way.

Each NDJSON line is written with a synchronous, looping write, and JSON.stringify escapes any newline inside a message, so a hostile hostname cannot split one event across two lines. One line is always exactly one record.

See also

Last updated on

On this page