Debug
palbase logs shows the server's view of a deployment. palbase debug shows the client's — every request the Palbe SDK made, every log line, and everything the app pushed in with pb.debug: the request that never left, the 401 nobody surfaced, the body that came back empty. It has exactly two subcommands. tail reads the records an app already wrote inside its simulator container on this machine, with no network and no credentials; attach <code> streams a real device's console live, using a pairing code that device shows. Both render the same envelope through the same formatter, so a line looks identical whichever way you are watching.
Quick example
palbase debug tail --follow
▸ 6F3A2E10-1C4D-4B77-9A2E-0B15D8C7E441 · session C18DE7B8-170A-4D44-A765-B530E9322EC6
20:34:18.904 ✓ info harness booted [harness]
20:34:18.905 ✓ warning cart is empty [checkout] {sku=A1 userId=u_42}
20:34:18.909 ✗ error The request timed out. [images] {code=-1001 domain=NSURLErrorDomain}
20:34:18.912 ✓ GET 200 https://httpbin.org/json (1.5s) 276B
20:34:20.475 ✓ POST 202 https://k3xq81w4m.palbase.studio/v1/analytics/perf (318ms) 26B req=req_ef6acce5
20:34:20.913 ✗ POST 401 https://k3xq81w4m.palbase.studio/auth/login (877ms) 153B req=req_019fa49b
Every line is a record from the app's in-app console — the same data the iOS debug console shows on screen. The req= value is the server's X-Request-Id, so a failing line here and a line in palbase logs can be matched exactly.
Note: There is no
palbase debug history. It read records back days later, and reading them back requires a store that retains them — this one keeps aggregates, not one record per request. The iOS SDK's own doc comment forpb.debug.report()still names that command; it is a stale string, not a surface.
tail — a simulator on this machine
The app must be running in an iOS Simulator on this machine and link a Palbe version with pb.debug. Nothing has to be switched on in the app: the SDK records from launch.
A simulator's app container is a directory on your disk, and tail reads the session files the SDK already writes there — Library/Application Support/PalbeConsole/sessions/<id>.jsonl inside the container. No network, no credentials, no link required, and it works with the device offline. That path is a contract with the SDK: change one side without the other and the command reports no console data rather than failing.
tail cannot watch a real device — that is attach, below.
| Flag | Default | Meaning |
|---|---|---|
--follow, -f | off | Keep watching, re-reading every 500 ms. Relaunching the app switches to its new session automatically. |
--limit N | 50 | How many existing records to print before following. 0 prints none. |
--errors | off | Only failed requests and error-level logs. |
--json | off | Emit the raw records, one JSON object per line. |
--app <bundle-id> | auto | Pick an app, when several on the simulator use the SDK. |
--device <udid> | booted | Target a specific simulator. |
The banner names the app's container directory and the session file it settled on, and a relaunch announces the switch rather than tailing a dead file. Neither the simulator's UDID nor its name is ever printed — --device is how you choose one, not how you read one back:
▸ 6F3A2E10-1C4D-4B77-9A2E-0B15D8C7E441 · session C18DE7B8-170A-4D44-A765-B530E9322EC6
▸ new session — 6F3A2E10-1C4D-4B77-9A2E-0B15D8C7E441 · session 4A1C9E02-88B7-41F0-9C2E-7D3A6F5B1E44
Reading a line
20:34:20.913 ✗ POST 401 https://…/auth/login (877ms) 153B req=req_019fa49b
└ time │ └ method │ └ url │ duration │ size └ server request id
└ ✓ done · ✗ failed · … in flight
A log line carries its level, message, [label] and any metadata as {key=value}. Records are read up to 8 MiB per line, so a full-fidelity build's inline bodies are not silently dropped.
Piping it
--json emits exactly what the SDK wrote, so it composes:
# only 5xx responses
palbase debug tail --json --limit 0 -f \
| jq -c 'select(.network.statusCode >= 500)'
# every request the app made to a third-party host
palbase debug tail --json | jq -r '.network.url' | grep -v palbase.studio
attach — a real device, live
palbase debug attach K7M4-P2QX
▸ todoapp/main · attaching to debug:project:0eb72868-7cf3-4887-bcf2-…
▸ attached — waiting for records. Nothing is replayed: you see what the device does from now on.
Same flags as tail for the output itself: --json for one raw record per line, --errors for only failed requests and error logs. Records go to stdout and status lines to stderr, so piping to a file stays clean.
What attach needs
Unlike tail, attach is target-relative: it acts on the project this checkout is linked to, and it needs that project's credential. The code alone is not enough, and it is not Studio that checks — a code is armed on the project, and the project is the only thing that can turn it into a topic:
POST <target>/rt/v1/debug/sessions/resolvewith the code, carrying the credential for that target, returns the session id.- A WebSocket opens at
<target>/realtime/v1/websocket?vsn=2.0.0and joins the topicdebug:<stack-ref>:<session-id>. Every project on this cloud boots with the stack refproject, so the topic readsdebug:project:<session-id>.
With no credential the command says so before anything else happens: not signed in to https://k3xq81w4m.palbase.studio — run `palbase login` . A leaked code is therefore useless to somebody who cannot already reach the project.
Getting a code
The device arms the session itself, from the app — await pb.debug.startLiveSession(), or the Start Live Session control in the console's own Settings tab. The code is 8 characters of Crockford base32 (0123456789ABCDEFGHJKMNPQRSTVWXYZ — no I, L, O or U), and it is meant to travel by human: read aloud on a call, pasted into a chat.
Dashes, spaces and letter case are normalised away, and the classic misreads are folded — I and L become 1, O becomes 0 — so k7m4-p2qx, K7M4P2QX and K7M4-P2QX are the same code. Anything else is refused locally, before a request:
"K7M4-P2Q" is not a pairing code: expected 8 characters, got 7. The device shows
the code when it arms; the dash and the letter case do not matter
Metadata only, unless the app said otherwise. Bodies and header maps are stripped on the device before broadcast, and a viewer cannot ask for more — full fidelity is the app build's decision. Nothing is replayed: joining late shows you what happens next, not what you missed, and nothing is stored server-side.
When attach refuses
Two different moments refuse for different reasons. Resolving the code is an HTTP call, and each status gets its own sentence rather than a shared "the project answered":
| Status | What it means |
|---|---|
404 | No device is showing that code — check it, or ask for a fresh one. |
410 | That session has expired; the device can arm a new one. |
401 / 403 | The project "did not accept this session" — run palbase login. |
429 | Too many attempts on that code. Wait, or have the device arm a fresh one. |
Joining the topic can then still be refused, and a rejection is terminal — the same code presented again gets the same answer, so the reconnect loop stops rather than being quietly refused every two seconds:
| Reason | What happened |
|---|---|
unknown_session | The session ended. The device disarmed it — closing or backgrounding the app both do that — so this code is now dead. Arm again on the device. |
expired | The session's TTL ran out. Sessions are never extended from this side; arm again for a fresh code. |
invalid_code | The code does not match this session. Arming again invalidates the previous code. |
not_publisher | The server took this connection for the device that armed the session. attach never joins as a publisher, so there is nothing to correct — arm again, and report it if it repeats. |
forbidden | Not allowed on this session. A viewer may watch and nothing else. Check that the project this checkout is linked to, and the environment it resolves to, are the ones the device armed against — palbase status shows it; pass --env <name> to attach to another environment, and palbase env use <name> remembers the choice. |
Note:
attachacts on the environment this checkout resolves to, like every target-relative verb: a code armed on staging needs--env staging, or apalbase env use stagingselection first. A device armed against another project needs a checkout linked to that project. See Which environment a command acts on.
Staying attached
A dropped socket is not a refusal, so attach reconnects on its own with capped exponential backoff — 1 s doubling to 30 s, reset after a connection that lived more than a minute — and holds the socket open with a 25 s heartbeat:
▸ disconnected (unexpected EOF) — reconnecting in 1s
If the device and the CLI disagree about the record format, the mismatch is reported once rather than rendered as a confident wrong value:
▸ this device sends schemaVersion=2 and this CLI renders 1 — those records are NOT being rendered.
Update the CLI, or the Palbe version the app links, so both speak one version.
With --json the bytes still go out verbatim, because --json interprets nothing.
Troubleshooting
"no Palbe console data found on the simulator" — the app has not run yet on this simulator, or it links a Palbe version without pb.debug. Launch it once.
"no booted simulator. Start one, or pass --device <udid>" — list them with xcrun simctl list devices booted.
Records stop appearing — the app was reinstalled, which replaces the container. Restart the command; with --follow it re-resolves on its own.
startLiveSession() returned nothing — it answers nil rather than throwing, in four cases: the console is not enabled for that user, nobody is signed in, the device is offline or the SDK was never configured, or the platform rate-limited repeated arming. See Debug Console.
Related
- Debug Console — the in-app console, what it records, who may see it, and arming a live session
- Deploying —
palbase logs, the server's view of the same request - Linking a Checkout — the link
attachresolves a code against - Overview — the full command surface, and what a directory is bound to