Sonajson-mode

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 — always true for 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 — always false for 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 as sona <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.maskedtrue when secrets were rewritten as ****<last 4>;

false when --no-mask was given (consumer should treat the document as containing live credentials).

  • result.config — the parsed sona.yml document. 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]null when 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 --count shipped here. update (install/rollback) + init (interactive wizard) + uninstall (delete paths) deferred — side-effecting commands whose --json design needs more thought than the read-only paths.

Source of truth: TnsAI.Sona/docs/json-mode.md. This page exists so the advertised /docs/sona/json-mode URL resolves without putting Sona back in the framework docs sidebar.