Output formats
Two output backends: ConsoleOutput (default, ANSI record blocks and trees) and JsonOutput (--json, thin json_encode wrapper). Pick by intent — humans get console, scripts get JSON.
Two output backends. Every command picks one based on whether
--json was passed.
| Backend | Trigger | Best for |
|---|---|---|
ConsoleOutput |
default | Humans on a terminal — record blocks, trees, colours |
JsonOutput |
--json |
Scripts, CI, log shipping — json_encode pass-through |
Console output
The default. Designed to be readable at a glance. The backend has five primitives:
data(array)— single record, onekey: valueline per field, used byread/write/trust.table(array)— multiple records, one block per row, blank line between rows. Used byendpoints,trust:list.tree(array)— recursive ASCII tree, used bybrowse.writeln(string)— plain text line, used bywatch,dump:nodesetprogress lines,trust:remove.error(string)— red text on stderr.
Record block (data)
A read prints a record block:
NodeId: ns=2;s=PLC/Speed
Attribute: Value
Value: 42.5
Type: Double
Status: Good (0x00000000)
Source: 2026-05-15T10:30:00+00:00
Server: 2026-05-15T10:30:00+00:00
Keys are padded to a common width and colored cyan; values follow. There is no "header row + data rows" layout — each command always emits the field names alongside the values.
Multi-row table (table)
An endpoints invocation prints one record block per endpoint,
separated by blank lines:
Endpoint: opc.tcp://plc.local:4840
Security: None (mode: None)
Auth: Anonymous
Endpoint: opc.tcp://plc.local:4840
Security: Basic256Sha256 (mode: SignAndEncrypt)
Auth: Anonymous, Username, Certificate
Same shape as data but repeated. There is no aligned-column
layout (no URL Security Mode Auth header) — the backend
prints field-by-field.
Tree (tree)
A browse --recursive prints a tree of name (nodeId) [class]:
├── Server (i=2253) [Object]
│ ├── ServerStatus (ns=0;i=2256) [Variable]
│ └── NamespaceArray (ns=0;i=2255) [Variable]
└── DeviceSet (ns=2;i=5001) [Object]
└── PLC1 (ns=2;i=5002) [Object]
├── Speed (ns=2;i=5003) [Variable]
└── Mode (ns=2;i=5004) [Variable]
Each line shows the display name, the canonical NodeId in
parentheses, and the node class in brackets. The tree connectors
(├──, └──, │) are always emitted; colors are applied when
the output is a TTY.
Colours
When stdout is a terminal (and NO_COLOR is unset), ANSI
sequences highlight keys and tree branches. Piped to another
command, the CLI detects the non-TTY and emits plain text.
NO_COLOR=1 disables colour. There is no FORCE_COLOR override;
to force colour through a pipe, allocate a PTY (script,
unbuffer).
JSON output
With --json, every command emits structured JSON. The backend
is intentionally thin — JsonOutput calls json_encode on the
same PHP arrays the console formatter would render, with no
key rewriting and no wrapping envelope. The schema therefore
matches the PascalCase keys baked into each command.
Browse — single level
BrowseCommand builds [{name, nodeId, class}, ...] and passes it
to tree(). JsonOutput::tree emits it verbatim:
[
{"name": "Server", "nodeId": "i=2253", "class": "Object"},
{"name": "DeviceSet", "nodeId": "ns=2;i=5001", "class": "Object"}
]
Browse — recursive
For --recursive, the same array is nested; each node grows a
children key when it has descendants. There is no top-level
wrapping object with the parent's metadata:
[
{
"name": "Server",
"nodeId": "i=2253",
"class": "Object",
"children": [
{"name": "ServerStatus", "nodeId": "ns=0;i=2256", "class": "Variable"}
]
},
{"name": "DeviceSet", "nodeId": "ns=2;i=5001", "class": "Object"}
]
Read
{
"NodeId": "ns=2;s=PLC/Speed",
"Attribute": "Value",
"Value": "42.5",
"Type": "Double",
"Status": "Good (0x00000000)",
"Source": "2026-05-15T10:30:00+00:00",
"Server": "2026-05-15T10:30:00+00:00"
}
| Field | Meaning |
|---|---|
NodeId |
The NodeId you passed (string) |
Attribute |
The --attribute name resolved on the CLI side |
Value |
Stringified value — scalars become their string form; arrays/objects become JSON; booleans → "true"/"false" |
Type |
The BuiltinType name (e.g. "Double", "String"), not the numeric ID |
Status |
Combined status string "<Name> (0x<hex>)", never a bare integer |
Source |
sourceTimestamp formatted with DateTimeInterface::format('c'), or "N/A" |
Server |
serverTimestamp formatted likewise |
Write
{
"NodeId": "ns=2;s=PLC/Setpoint",
"Value": "42.5",
"Type": "Double",
"Status": "Good (0x00000000)"
}
Four fields, all strings. There is no separate statusCode /
statusName integer — the human-readable Status string carries
both. Type is the BuiltinType name (or "Auto-detected" when
no --type was supplied and the cached read result is unavailable).
Endpoints
EndpointsCommand builds an array of {Endpoint, Security, Auth}
rows and passes it to table(). JsonOutput::table emits the
array verbatim — no wrapping endpoints key:
[
{
"Endpoint": "opc.tcp://plc.local:4840",
"Security": "None (mode: None)",
"Auth": "Anonymous"
},
{
"Endpoint": "opc.tcp://plc.local:4840",
"Security": "Basic256Sha256 (mode: SignAndEncrypt)",
"Auth": "Anonymous, Username, Certificate"
}
]
Security is one combined string in the form
"<policyName> (mode: <modeName>)". There are no separate
securityPolicyUri, securityLevel, or userIdentityTokens
fields — only the Auth summary string with a comma-separated list
of token type names.
To filter the Basic256Sha256 + SignAndEncrypt rows with jq:
opcua-cli endpoints opc.tcp://plc.local:4840 --json \
| jq -r '.[] | select(.Security == "Basic256Sha256 (mode: SignAndEncrypt)") | .Endpoint'
Watch
NDJSON — one JSON object per change notification (or per poll
tick). The backend wraps the same plain-text line the console
mode prints in {"message": "<line>"}:
{"message":"[10:30:00.123] 42.5"}
{"message":"[10:30:01.487] 42.7"}
There is no separate value, statusCode, or structured
timestamp field. Parse the string inside .message to extract
either. See watch for details.
Trust list
TrustListCommand builds an array of {Fingerprint, Subject, Expires}
rows. JsonOutput::table emits it verbatim:
[
{
"Fingerprint": "a1:b2:c3:d4:...",
"Subject": "CN=PLC-1, O=ACME",
"Expires": "2027-01-01T00:00:00+00:00"
}
]
Fingerprint— SHA-1 hex pairs joined by:(the same format the CLI prints fromtrust).Subject— the X.509 subject CN, or"Unknown".Expires—notAfterformatted withDateTimeInterface::format('c')(ISO 8601 datetime with timezone), or"N/A".
There is no wrapping trustedCertificates envelope.
Trust
trust emits a single record (data()):
{
"Status": "Trusted",
"Fingerprint": "a1:b2:c3:d4:...",
"Subject": "CN=PLC-Server, O=ACME",
"Expires": "2027-01-01T00:00:00+00:00"
}
Errors
When a command fails, the error is emitted as JSON on stderr
(not stdout), with a single error key — no additional fields:
{"error":"Server certificate not trusted."}
The fingerprint of the offending certificate is sent on the next
error() call:
{"error":" Fingerprint: a1:b2:c3:d4:..."}
Each error() call produces one {"error":"..."} line. The CLI
never serialises structured error metadata (fingerprint,
statusCode, statusName) as keys alongside error — that is
purely the human message.
Writeln in JSON mode
When a command calls writeln() (progress lines, trust:remove,
empty-result notices), JsonOutput::writeln wraps it as
{"message": "<text>"}:
{"message":"Removed certificate: a1:b2:c3:d4:..."}
This is why watch NDJSON is {"message": "[10:30:00.123] 42.5"}
rather than a structured object.
When to use which
| Context | Format |
|---|---|
| Operator at a terminal | Console |
Shell pipeline (opcua-cli read … | awk …) |
JSON (| jq) |
| CI smoke test | Either; JSON parses cleaner |
| Log shipping | JSON |
| Demoing to a non-technical audience | Console |
A reasonable script pattern:
opcua-cli read opc.tcp://plc.local:4840 i=2261 --json | jq -r .Value
# → open62541 OPC UA Server
jq -r strips quotes; the result is the raw string suitable for
further shell processing. Note the PascalCase .Value.
Combining --json with --debug
These two flags conflict — --debug adds log lines to stdout,
which would corrupt the JSON. The CLI rejects the combination
with an explicit error.
Use --debug-stderr or --debug-file instead:
opcua-cli read opc.tcp://plc.local:4840 i=2261 --json --debug-stderr 2>debug.log
Stdout is clean JSON; stderr captures debug detail. See Debug logging.
What the JSON output is not
The JSON output is a thin json_encode of the same PHP arrays
the console formatter would render. That means:
- No key rewriting. Field names are PascalCase
(
NodeId,Status,Fingerprint) — they are not the camelCase names from the OPC UA specification. - No wrapping envelope. Multi-row commands return a JSON
array, not an object with a
data/endpoints/trustedCertificateskey. - No separate numeric and named status fields.
Statusis always a combined string"<Name> (0x<hex>)". - No nested DataValue.
readreturns the formattedValue/Type/Status/Source/Serverflat fields, not a nested OPC UADataValuestructure.
Scripts that need a different shape should re-shape after jq,
or embed opcua-client directly for typed access.