sona <command> --json schema
External agents that orchestrate Sona enumerate commands via sona --help --json and consume each command's output as a stable envelope. This doc is the schema contract; don't deviate from it in new commands without bumping the contract version.
Envelope shape
Every non-streaming --json invocation emits one line of ndJSON. Two variants:
Success
{"ok": true, "command": "<name>", "result": { ... }}ok— alwaystruefor success.command— the command that produced the result (e.g."status",
"version", "help").
result— command-specific payload; see per-command sections below.
Error
{"ok": false, "command": "<name>", "error": {"code": "<id>", "message": "<text>"}}ok— alwaysfalsefor error.command— the command that failed.error.code— short machine-readable identifier
(e.g. "not_running", "invalid_argument", "network_error"). Stable; new codes can be added but never renamed without a contract bump.
error.message— human-readable explanation. May change wording
between releases; don't parse it.
Streaming exception: sona chat --json
chat --json is per-chunk ndJSON (one JSON document per LLM token / tool event), terminated by a done envelope. That contract was established in TNS-440 and lives separately — not under this envelope.
Per-command output
sona --version --json
{
"ok": true,
"command": "version",
"result": {
"version": "0.3.0",
"jre": "21.0.4+7-LTS"
}
}sona --help --json
{
"ok": true,
"command": "help",
"result": {
"version": "0.3.0",
"commands": [
{"name": "init", "summary": "Re-run the interactive setup wizard."},
{"name": "status", "summary": "Report whether the Sona daemon is currently running."},
...
],
"globalOptions": [
{"name": "--version", "alias": "-v", "summary": "..."},
{"name": "--help", "alias": "-h", "summary": "..."},
{"name": "--config", "summary": "..."},
{"name": "--pair", "summary": "..."}
]
}
}commands[].name— invokable assona <name>.commands[].summary— one-line description.globalOptions[].alias— optional short flag (e.g.-v).
sona status --json
Running:
{"ok": true, "command": "status", "result": {"running": true, "pid": 12345}}Not running, no pidfile:
{"ok": true, "command": "status", "result": {"running": false, "reason": "no_pidfile"}}Not running, stale pidfile (daemon crashed):
{"ok": true, "command": "status", "result": {"running": false, "reason": "stale_pidfile", "pidfile": "/home/user/.sona/sona.pid"}}status always returns ok: true (it's a query); the running boolean + reason carry the actual state. Exit code is non-zero when not running so shell scripts can branch without parsing JSON.
sona config show --json
{
"ok": true,
"command": "config",
"result": {
"masked": true,
"config": {
"llm": {"provider": "anthropic", "model": "claude-sonnet-4-20250514", "api_key": "****1234"},
"channels": {"telegram": {"token": "****abcd"}},
"workspaces": {"default": { ... }}
}
}
}result.masked—truewhen secrets were rewritten as****<last 4>;
false when --no-mask was given (consumer should treat the document as containing live credentials).
result.config— the parsedsona.ymldocument. Shape mirrors the
on-disk YAML.
Error envelopes:
{"ok": false, "command": "config", "error": {"code": "no_config", "message": "No config found — run `sona init` first."}}
{"ok": false, "command": "config", "error": {"code": "read_failed", "message": "Failed to read config: ..."}}sona cost --json
{
"ok": true,
"command": "cost",
"result": {
"window": "24h",
"since": "2026-05-13T08:00:00Z",
"filters": {"channel": null, "workspace": null, "provider": null},
"totals": {"messages": 42, "inputTokens": 12000, "outputTokens": 3400, "totalLatencyMs": 18500, "usd": 0.4231},
"byChannel": {"telegram": {...}, ...},
"byWorkspace": {"default": {...}, ...},
"byProvider": {"anthropic": {...}, ...},
"byModel": {"claude-sonnet-4-20250514": {...}, ...},
"byDay": {"2026-05-14": {...}, ...},
"budgets": {"month": {"limit": 50.0, "spent": 12.34, "ratio": 0.247, "status": "ok"}, ...}
}
}window—"24h" | "week" | "month" | "year" | "since:<date>".since— explicit ISO-8601 lower bound (always populated).filters[ch|ws|prov]—nullwhen no filter was passed (distinguishes
"no filter" from "filter for empty channel").
budgets[period].status—"ok" | "warn" | "over".
Error envelope:
{"ok": false, "command": "cost", "error": {"code": "audit_read_failed", "message": "Failed to read audit log: ..."}}sona search "<query>" --json
{
"ok": true,
"command": "search",
"result": {
"query": "postgres",
"count": 3,
"results": [
{
"namespace": "dGVsZWdyYW06dXNlcjE",
"channel": "telegram",
"role": "user",
"message_index": 42,
"score": 5.4331,
"snippet": "...how to migrate **postgres** to ...",
"content": "Full message body verbatim.",
"timestamp": "2026-05-14T08:00:00Z"
}
]
}
}result.count— number of hits returned (capped at--limit, default 10).result.results[].score— BM25 score; higher is better but absolute value isn't meaningful across queries.
sona memory stats --json
{
"ok": true,
"command": "memory",
"result": {
"sessionCount": 4,
"totalMessages": 82,
"sessionsBytes": 5616,
"profileCount": 5,
"profilesBytes": 2595,
"oldestSession": "2026-05-12T20:12:20Z",
"newestSession": "2026-05-14T07:55:33Z"
}
}oldestSession / newestSession are omitted when sessionCount is 0.
sona telemetry status --json
{
"ok": true,
"command": "telemetry",
"result": {
"enabled": false,
"endpoint": "https://tnsai.dev/sona/telemetry",
"lastSent": null
}
}lastSent is null until the first 24 h tick has fired.
sona stop --json
Success — daemon was running:
{"ok": true, "command": "stop", "result": {"stopped": true, "pid": 12345, "used_force": false}}Not running (no pidfile):
{"ok": true, "command": "stop", "result": {"stopped": false, "reason": "not_running"}}Stale pidfile (cleaned up):
{"ok": true, "command": "stop", "result": {"stopped": false, "reason": "stale_pidfile", "pid": 12345, "pidfile_cleaned": true}}Errors (exit code 1):
{"ok": false, "command": "stop", "error": {"code": "signal_rejected", "message": "..."}}
{"ok": false, "command": "stop", "error": {"code": "timeout", "message": "..."}}
{"ok": false, "command": "stop", "error": {"code": "interrupted", "message": "..."}}used_force reflects whether --force / -f was passed.
sona logs --count --json
One-shot count:
{"ok": true, "command": "logs", "result": {"count": 42}}Follow mode (--follow + --count + --json) emits one envelope per tick (ndJSON):
{"ok": true, "command": "logs", "result": {"count": 42}}
{"ok": true, "command": "logs", "result": {"count": 43}}The non-count logs paths (raw lines / --grep / --follow without --count) intentionally stay text-only — they're line-stream output, not envelope events.
sona doctor --json (since TNS-256)
Pre-existing contract — emits the full check list with per-check status, not the envelope shape above. Documented under the doctor docs.
Scope status
Contract surface as of TNS-466 (pre-migration Linear ID; the per-axis issues now live in Linear team TAN):
- Axis 1 ✅ —
--version,--help,status(PR #168). - Axis 2 ✅ —
config show,cost(PR #171). - Axis 3 ✅ —
search,memory stats,telemetry status(PR #170). - Axis 4 ⏳ —
stop,logs --countshipped here.update(install/rollback) +init(interactive wizard) +uninstall(delete paths) deferred — side-effecting commands whose--jsondesign needs more thought than the read-only paths.