* feat(access-policy): --access-policy enforce, and prove it on a real daemon
`--access-policy` already reached the runtime; what was missing was a way to
ask for deny-by-default without hand-writing JSON, and any evidence that it
works. The README actively said the opposite ("enforcement is not yet
implemented ... a no-op for now") — it has been enforced for a while.
resolveAccessPolicyArg moves out of main.cpp into daemon/access_policy_arg.
so it can be unit-tested, and gains one spelling: the literal `enforce`
expands to {"version":1,"mode":"enforce","restrictions":{}}. That is not a
second switch — `mode` is still the runtime's only switch — it is the bare
document that arms it. Checked before the file branch, so arming enforcement
can't depend on the daemon's working directory.
The integration tests are the point: same binaries, same modules, same call,
policy the only variable. test_ipc_module declares test_basic_module and
test_extlib_module; test_basic_module declares nothing.
no flag -> requestModule(test_basic_module, test_extlib_module) mints
enforce -> the same call is refused, and both names appear in the log
enforce -> requestModule(test_ipc_module, test_basic_module) still mints
The third is the one that matters; a change that refused everything would
pass the second on its own. The refusal is matched structurally rather than
by exact text because the two capability_module implementations in this tree
quote the names differently.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* feat(qt-host): link the Qt host runtime from logos-plugin-qt, not logos-qt-sdk
logoscore's daemon and its in-process core service are built on LogosAPI,
LogosAPIProvider and LogosProviderObject. Those moved out of logos-qt-sdk
into logos-plugin-qt, which publishes them as the `logos-qt-host` package
with the CMake target logos-qt-host::logos_qt_host. Point at that target.
Those three headers were the ONLY thing this repo took from logos-qt-sdk —
it emits no Qt consumer wrappers, ships no UI plugin, and never touches
logos_qt_lp_bridge.h or logos_ui_plugin_context.h — so the logos-qt-sdk
input is dropped outright rather than kept alongside. LOGOS_QT_SDK_ROOT
becomes LOGOS_QT_HOST_ROOT in all three derivations (build, tests,
buildPortable), and `--version` now reports the logos-plugin-qt commit.
Both new failure modes are hard errors, never silent skips: an unset
LOGOS_QT_HOST_ROOT is a FATAL_ERROR before find_package runs, and a
find_package that somehow does not define the imported target is a
FATAL_ERROR too.
logos-qt-host needs TokenManager::forIdentity/isolateIdentity, which
logos-protocol only grew on its per-client-token-store commit, so the
lock moves there. logos-plugin-qt is rev-pinned for now because
nix/qt-host.nix does not exist on its default branch yet.
Verified on aarch64-darwin: `nix build .#checks.aarch64-darwin.tests`
passes 21/21 with the committed lock and no overrides (same 21 as the
pre-change baseline), .#cli and .#cli-bundle-dir build, and the set of
LogosAPI/LogosAPIProvider/LogosProviderObject/qtArgDecode symbols in the
logoscore binary is identical to the pre-change build.
* chore(deps): re-pin the SDK stack onto the pushed b4 revs
Rebased onto master, so the inputs have to name the revs the rest of the b4
stack was actually pushed at rather than each input's default branch:
logos-cpp-sdk a04b2788 b3 codegen tip; a strict descendant of
cpp-sdk master, so forward-only
logos-protocol c8bab12 per-client token store — logos-qt-host
calls TokenManager::forIdentity, which
exists nowhere else
logos-plugin-qt cc24fa1 was 8ccb1fc. The superset branch that
logos-liblogos and logos-module-builder
also pin, so exactly ONE logos-qt-host
is in the closure — this CLI links it
directly AND through liblogos_core
logos-liblogos f2a15ef the liblogos built on that same qt-host
logos-capability-module 0cb33fb master, pinned explicitly — see below
All five are rev-pinned in the URL rather than left to the lock: every one is
a branch commit, so an unpinned url lets `nix flake update` silently relock
onto a default branch that does not build here.
capability_module deliberately does NOT move to the universal port (07dba1f).
That port declares metadata.json#host_services and fails closed until a host
calls logos_module_grant_host_services — and nothing in this stack calls it
yet (neither logos-liblogos nor logos-plugin-qt contains a single call site).
Built against it, the daemon's capability gate refuses EVERY requestModule
with "not granted the token_registry host service", so no module can call
another; the new access-policy integration test caught exactly that. 0cb33fb
is what logos-liblogos and logos-standalone-app lock too.
Verified on aarch64-darwin with the committed lock and no overrides:
.#checks.aarch64-darwin.tests-logosctl 191 + 25 + 21 tests, all PASSED
.#checks.aarch64-darwin.tests-logoscore 20 + 24 tests, all PASSED
.#checks.aarch64-darwin.tests built (exit 0)
.#packages.aarch64-darwin.{cli,ctl} built (exit 0)
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* chore(deps): rev-pin logos-test-modules at the b4 qt-host tip
The daemon-backed integration checks load these plugins into the daemon
this repo builds, so the two share one host runtime in one process image
-- the same constraint that already rev-pins logos-liblogos. a639b934
links the test modules against logos-qt-host rather than logos-qt-sdk and
carries the matching B4 stack pins; the previous lock sat on master
(f8077fab), which predates that repoint.
The URL had to change, not just the lock. The input was an UNPINNED url,
so it resolved to the default branch -- and f8077fab IS master's tip.
`nix flake update logos-test-modules` was therefore a silent no-op that
would leave the ten b4 commits behind while reporting success.
f8077fab is a strict ancestor of a639b934 (verified on a non-shallow
clone), so this is forward-only, not a lineage switch.
Two behaviour changes ride along and were checked against this repo's
assertions rather than assumed safe:
* test_basic_module and test_extlib_module migrate to
interface "universal". Neither declares metadata.json#host_services,
so the fail-closed gate that keeps logos-capability-module pinned off
its universal port does not apply here.
* stringLength now answers in CHARACTERS, not bytes. Every assertion
here is ASCII ("abcdef" -> 6), so the two agree.
The access-policy fixture still has its pair: test_ipc_module declares
[test_basic_module, test_extlib_module] and test_basic_module declares
none, so basic -> extlib stays undeclared.
Checks built by name, all exit 0: tests-logosctl, tests-logoscore,
tests. 281 tests, 0 failures, 0 skips.
* test: use test_ipc_new_api_module as the transitive-dependency fixture
These integration tests pick a module that DECLARES the other two, so one
load-module has to pull all three, and then request a token across that edge.
test_ipc_module was that fixture; it is being retired as a duplicate. Its
successor declares exactly the same dependency pair, so the fixture role
transfers unchanged.
Worth doing in the same breath as the retirement rather than after: these call
GTEST_SKIP() when the module is missing, so deleting the module out from under
them would not have turned anything red — the dependency-resolution and
token-request coverage would simply have stopped running.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* fix(windows): refuse an unknown target instead of silently skipping it
`logos_use_shared_runtime_from_dll` empties the static archive of each named
IMPORTED target so the symbol resolves to liblogos_core.dll's exported copy
instead. It skipped any name that was not a target, which makes a typo or a
moved target silent — and the failure it hides is the duplicate-statics class:
the image keeps its own static copy of the shared runtime alongside the DLL's,
and PE has no interposition to collapse the two.
That hazard was already WRITTEN DOWN at basecamp's call site ("naming the old
target here would be a silent no-op … Windows would regress to the 29
'rejecting unauthorized call' lines this shim exists to prevent") — documented,
but not enforced. This enforces it.
Taken from feat/sdk-codegen-phase-a, which hardened its logoscore-cli copy and
never fixed basecamp's; feat/sdk-codegen-b3 has neither. It is the one place
where reconciling onto b3 would otherwise lose work, so both copies get it.
Behaviour is unchanged for every current caller: the function early-returns off
Windows, and both call sites pass the same two targets
(logos-qt-host::logos_qt_host, logos-protocol::logos_protocol) that phase-a's
hardened copy already accepts. x86_64-windows still evaluates (386 packages).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* ci: use logos-co/setup-nix-cache-action for Nix setup and caching
Replaces the per-repo installer + cachix pair with the shared action, which
installs Nix with the Logos Attic cache (cache.nix.logos.co) preconfigured and
publishes what the job builds — master to the public cache, every other ref to
ci.
Each converted job also gains
environment: ${{ github.ref == 'refs/heads/master' && 'public-cache' || '' }}
because ATTIC_TOKEN_PUBLIC only exists inside that environment. Without it the
secret resolves empty on master and publishing is silently skipped — the job
still passes, so the omission would not show up as a failure.
The action installs Nix itself on every runner, macOS included. That is a
deliberate reversal of the workaround these files carried: the comments here
said cachix/install-nix-action collides with the runner's pre-existing _nixbld
users (eDSRecordAlreadyExists), so DeterminateSystems' installer was used
instead. It no longer reproduces — logos-delivery-module has already been
converted the plain way and its `build-and-test (macos-latest)` leg passes.
Keeping the workaround would have meant a second installer plus a duplicated
substituter/key block in ten files, guarding against something two green runs
say does not happen. If it ever recurs it fails loudly at install, which is
recoverable; the silent-skip above is the failure mode worth engineering
against.
One property is deliberately NOT carried over: the old cachix step ran with
`continue-on-error: true` so a failed cache push could not fail a job whose
tests passed. The action exposes no equivalent, and adding one here would also
swallow genuine setup failures now that the same step installs Nix rather than
only publishing at the end.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* docs: drop references to removed generator flags and interfaces
README and docs described module authoring in terms of LogosProviderBase,
LOGOS_METHOD and --provider-header, none of which exist. Updated to the
universal model, keeping the retired shapes named as history.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* chore(deps): track master for protocol, cpp-sdk and plugin-qt
logos-protocol#59, logos-cpp-sdk#138 and logos-plugin-qt#19 merged, so the three
rev pins bridging to them are retired, each with its rationale rewritten to name
the PR that closed the gap.
Left pinned: logos-liblogos, logos-capability-module and logos-test-modules —
their branches are still in flight and no merged upstream was confirmed.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
48 KiB
Logosctl CLI Specification
Overview
The logosctl CLI is the primary interface for operating the Logos Core runtime. It manages the lifecycle of a daemon process that hosts independently developed modules (plugins), and provides commands to load modules, call methods, watch events, and inspect runtime state.
The CLI follows a daemon + client architecture. A long-running daemon process hosts the module runtime, and short-lived client commands connect to it to perform operations.
Design Goals
- Human-friendly — Readable output, discoverable commands, helpful error messages with recovery suggestions.
- Agent-friendly — Structured JSON output, non-interactive operation, streaming events as NDJSON, deterministic exit codes. An AI agent using a bash tool should be able to operate the full lifecycle without any interactive prompts or ambiguous output.
- Composable — Each command does one thing and works well in pipelines. Output goes to stdout, diagnostics to stderr.
- Daemon-oriented — A long-running daemon owns the modules; clients connect to it. The daemon starts clean (
-m/--persistence-pathconfigure startup with-D); modules are loaded viaload-module.
Architecture
┌──────────────────────────┐
│ logosctl daemon │
│ │
│ ┌────────────────────┐ │
│ │ core_service │ │
│ │ (in-process module)│ │
│ └─────────▲──────────┘ │
│ │ │
│ Qt Remote Objects │
│ │ │
└────────────┼─────────────┘
│
┌────────────────┼────────────────┐
│ │ │
┌────────▼──────┐ ┌──────▼───────┐ ┌──────▼───────┐
│ logosctl │ │ logosctl │ │ logosctl │
│ load-module │ │ call chat │ │ watch chat │
│ waku │ │ send "hi" │ │ --event msg │
└───────────────┘ └──────────────┘ └──────────────┘
(exits) (exits) (streams)
Daemon (logosctl daemon start):
- Starts the Logos Core runtime and Qt event loop.
- Discovers modules in configured directories.
- Writes
~/.logosctl/daemon/state.json(live runtime state — instance_id, pid, started_at, resolved transports) on startup, removed on clean shutdown. - Maintains
~/.logosctl/daemon/tokens.json(hashed-at-rest accepted-token list — survives restarts). - Persists
~/.logosctl/daemon/config.json(operator preferences) only when the operator passed--persist-config. - Auto-issues an
autotoken for the local same-host client and emits~/.logosctl/client/config.json+~/.logosctl/client/auto.jsonon the first boot into an empty config dir.
Client commands (all other subcommands):
- Read
~/.logosctl/client/config.jsonto learn how to dial the daemon and which token file to load. - Connect to the daemon's
core_servicemodule via RPC using the token. - Execute the requested operation, print the result, and exit.
- If no daemon is running, exit with code 2 and a clear error message.
Command Structure
logosctl [global-flags] <command> [command-flags] [args...]
Global Flags
| Flag | Short | Description |
|---|---|---|
--json |
-j |
Output as JSON. Default when stdout is not a TTY. |
--modules-dir <path> |
-m |
Module search directory (daemon mode only, repeatable). |
--config-dir <path> |
Override the config directory (default: ~/.logosctl; also LOGOSCTL_CONFIG_DIR). Client commands must pass the same value as the daemon they target. The directory contains the daemon/ and client/ subtrees (see Authentication). |
|
--quiet |
-q |
Suppress non-essential output. |
--verbose |
-v |
Show debug/info/warning logs (suppressed by default). |
--help |
-h |
Show help. |
--version |
Show version. |
Daemon-side transport flags
The daemon defaults to a local Unix socket only for each well-known module
(core_service, capability_module). To expose either over the network,
pass one or more --module-transport flags; each opens an additional
listener that gets advertised in daemon/state.json's resolved block.
Local is always present. Every module the operator configures (and
the two well-known ones) implicitly gets a LocalSocket listener
prepended to its resolved transport set, in addition to whatever the
operator named via --module-transport. The operator's TCP / TCP+SSL
flags add additional outside-facing surfaces; they don't replace the
same-host LocalSocket. This keeps the same-host code paths (the
parent's capability_module handshake, the SDK's auto-requestModule
flow inside LogosAPIClient, cross-module getClient(name) calls)
working over LocalSocket regardless of which network transport the
operator chose. daemon/state.json's resolved.modules.<name>.transports[]
always lists the LocalSocket entry first, followed by operator-named
entries in the order they were typed.
| Flag | Applies to | Description |
|---|---|---|
--module-transport NAME=PROTOCOL[,k=v...] |
daemon | Repeatable. NAME is any module the daemon will load (well-known or user-configured). PROTOCOL is local, tcp, or tcp_ssl. Each occurrence adds one listener to the named module. If the flag is omitted entirely, every well-known module gets a single local listener; if it's passed without a local entry for NAME, a local listener is added implicitly so same-host callers always work. |
--insecure-tcp |
daemon | Allow tcp (plaintext) listeners on a non-loopback host. Without this flag, the daemon refuses to bind such a listener because tokens travel in cleartext. |
The k=v pairs after the protocol configure the listener:
| Key | Used by | Description |
|---|---|---|
host |
tcp, tcp_ssl | Bind address. Defaults to 127.0.0.1 for tcp. |
port |
tcp, tcp_ssl | Port (0 = auto-assign). |
codec |
tcp, tcp_ssl | Wire codec: json (default, debuggable) or cbor (compact). |
cert |
tcp_ssl | Server cert PEM file. |
key |
tcp_ssl | Server private key PEM file. |
ca |
tcp_ssl | CA cert PEM file. |
verify_peer |
tcp_ssl | true / false — require client cert verification. |
Each well-known module needs its own listener so the host-side client can dial each. Examples:
# TCP — plaintext, good for localhost or trusted networks. Local
# listeners are added implicitly; just name the TCP one for each
# module that needs an outside-facing surface.
--module-transport core_service=tcp,host=127.0.0.1,port=6000
--module-transport capability_module=tcp,host=127.0.0.1,port=6001
# TCP + TLS — wire-encrypted; cert + key required, CA optional. Local
# listeners are still added implicitly.
--module-transport "core_service=tcp_ssl,host=0.0.0.0,port=6443,cert=/p/c.pem,key=/p/k.pem,ca=/p/ca.pem"
--module-transport "capability_module=tcp_ssl,host=0.0.0.0,port=6444,cert=/p/c.pem,key=/p/k.pem,ca=/p/ca.pem"
# Per-module: applies to user modules too. The operator's TCP listener
# is the additional outside-facing surface; same-host callers still
# reach `my_module` over LocalSocket without extra configuration.
--module-transport my_module=tcp,host=127.0.0.1,port=6010
Client-side dial spec
Client commands never read daemon-only files (daemon/config.json,
daemon/tokens.json). They read <configDir>/client/config.json,
which holds the dial spec (endpoint, host, port, codec,
cert/key/ca/verify_peer for TLS) and a token_file pointing
at the raw-token file alongside. (status consults
daemon/state.json for a fast same-host liveness check via
kill(pid, 0), but never opens daemon-only secrets.)
The daemon auto-emits client/config.json + client/auto.json for the
local same-host case on the first boot into an empty config dir — local
clients work out of the box with no manual setup. Subsequent boots leave
an existing client/config.json alone (so an operator-written
remote-client config isn't clobbered). For remote clients
(port-forwarded containers, NAT, SSH tunnels) hand-write
client/config.json with the right host:port for each module and
reference a token_file whose contents was copied from a
daemon/tokens/<name>.json on the daemon host.
Commands
daemon / -D
Start the daemon process.
logosctl daemon start [--modules-dir <path>]...
logosctl daemon [--modules-dir <path>]...
Starts the Logos Core runtime in the foreground. Startup and shutdown messages go to stdout (so > logs.txt captures them); debug/info/warning logs go to stderr and are suppressed unless --verbose is passed. Writes ~/.logosctl/daemon/state.json on startup (and on the first fresh boot also emits ~/.logosctl/client/config.json + ~/.logosctl/client/auto.json for the local client), removes state.json on clean shutdown.
The daemon scans the configured module directories for available plugins and makes them available for loading via client commands.
load-module <name>
Load a module into the running daemon.
logosctl module load <name>
Resolves and loads the named module and all its dependencies. The module must be discoverable in one of the directories configured when the daemon was started.
unload-module <name>
Unload a module from the running daemon.
logosctl module unload <name>
list-modules
List available or loaded modules.
logosctl module ls [--loaded]
Without flags, lists all known (discovered) modules. With --loaded, lists only currently loaded modules.
Each module has a status: loaded, not_loaded, crashed, or loading. When a module has crashed, the output includes uptime (or - if not running) and crash metadata is available via module-info.
status
Show overall daemon and module health.
logosctl daemon status
Displays daemon state (PID, uptime, version, instance ID, configured listeners) and a summary of all modules with their status. This is the single "dashboard" command — it shows everything at a glance so agents don't need to chain multiple commands.
When the daemon is not running, exits with code 2 and suggests how to start it.
reload-module <name>
Unload and re-load a module.
logosctl module reload <name>
Performs an unload followed by a load in a single operation. Useful for recovering crashed modules or picking up configuration changes. If the module is not currently loaded, it falls back to a plain load (rather than erroring), reducing edge cases for agents that just want a module running.
module-info <name>
Show detailed information about a specific module.
logosctl module show <name>
Displays extended metadata: version, status, dependencies, available methods, emitted events, process info (PID, uptime), and crash details if applicable. Methods and events each carry their description (from the module's header doc comments) when documented. This is the deep-inspection counterpart to list-modules.
call <module> <method> [args...]
Call a method on a loaded module.
logosctl call <module> <method> [args...]
Invokes the named method on the specified module. Arguments are positional. Use the @file prefix to read a parameter value from a file.
Arguments are automatically type-coerced: numeric strings become integers or doubles, "true"/"false" become booleans, and everything else remains a string. This allows method signatures with typed parameters to match correctly.
logosctl call chat send_message "hello"
logosctl call storage load_config @config.json
logosctl call math twoArgs "hello" 2 # "hello" as string, 2 as integer
logosctl call config setBool "flag" true # "flag" as string, true as boolean
Alternative syntax (explicit form for readability):
logosctl module <name> method <method> [args...]
logosctl module chat method send_message "hello"
Both forms are equivalent. call is the short form; module ... method ... is the verbose form.
watch <module> [--event <name>]
Watch events from a loaded module.
logosctl watch <module> [--event <name>]
Streams events to stdout as they arrive. Without --event, streams all events from the module. Runs until interrupted (SIGINT / SIGTERM).
logosctl watch chat --event chat-message
logosctl watch chat --event chat-message >> events.log &
logosctl watch chat --event chat-message --json | jq .
stats
Show resource usage for loaded modules.
logosctl stats
Displays CPU and memory usage for each loaded module process.
stop
Stop the running daemon.
logosctl daemon stop
Sends a shutdown request to the daemon via core_service. The daemon performs a clean shutdown: unloads all modules, removes daemon/state.json, and exits. The client prints a confirmation message and exits.
If the daemon exits before the RPC response arrives (expected behavior), the client treats the connection loss as a successful shutdown.
Human:
$ logosctl daemon stop
Daemon stopped.
JSON:
$ logosctl daemon stop --json
{"status":"ok","message":"Daemon shutting down."}
info <module>
Alias for module-info <module>. See module-info above for full details.
logosctl module show <module>
Displays version, dependencies, available methods, and crash details (if applicable) for the named module.
issue-token --name <name>
Issue a new named token and write it to <configDir>/daemon/tokens/<name>.json.
logosctl token issue --name <name> [--replace] [--expires <dur>] [--local-only]
Appends an entry to <configDir>/daemon/tokens.json["tokens"] (a
{name, hash, issued_at, expires_at, local_only} row, hashes are SHA-256
hex) and writes a companion raw-value file at daemon/tokens/<name>.json
for distribution. Without --replace, the command refuses to overwrite an
existing token with the same name so a stale credential isn't silently
invalidated; pass --replace to rotate.
--expires <dur> sets a TTL after which the daemon rejects the token (e.g.
30d, 12h). --local-only marks the token as valid only over LocalSocket,
so even a compromised TCP listener can't replay it.
After copying daemon/tokens/<name>.json to the client host (typically into
the client's <configDir>/client/), the operator may delete the daemon-side
raw file — the daemon validates against the in-memory map seeded from
tokens.json["tokens"]'s hashes, not the raw file. Distribute the raw file
the way you'd distribute a private key; do not commit it to version control.
This command operates directly on the config dir on disk; it doesn't need the daemon to be running. Operator-issued tokens take effect on the next daemon restart (SIGHUP-driven reload is a follow-up).
revoke-token <name>
Remove a named token from <configDir>/daemon/tokens.json["tokens"].
logosctl token revoke <name>
After this returns, any RPC presenting the revoked token is rejected by the
daemon with an authentication error. The on-disk
daemon/tokens/<name>.json file is also removed so clients that still have
it can't mistake it for valid.
list-tokens
List all tokens currently issued against this config dir.
logosctl token ls
Shows token name, issued-at timestamp, expires-at, and the local-only flag —
never the plaintext token, which only lives in the
daemon/tokens/<name>.json file at the moment of issuance. Lost a token?
Rotate it with issue-token --replace.
Authentication
How Tokens Work
Logos Core uses UUID-based tokens for authentication. Every module loaded into the runtime receives a unique token generated by the core. These tokens are used to authorize RPC calls between components.
The CLI needs a token to authenticate with the daemon's core_service. This token is called the client token and is generated by the daemon on startup.
Token Lifecycle
1. DAEMON STARTS
logosctl daemon start --detach
→ Daemon mints an "auto" token (local_only=true) for the local client
→ Hash + metadata persisted into ~/.logosctl/daemon/tokens.json["tokens"]
→ Raw value emitted to ~/.logosctl/client/auto.json
→ ~/.logosctl/client/config.json written so local clients dial correctly
2. CLIENT CONNECTS
logosctl module load waku
→ Reads ~/.logosctl/client/config.json (dial spec + token_file)
→ Loads the raw token from the file token_file points at
→ Sends token with RPC request to `core_service`
→ `core_service` validates the token's hash against tokens.json["tokens"]
→ Request authorized, module loads
3. REMOTE / PROGRAMMATIC ACCESS
LOGOSCTL_TOKEN=<token> logosctl module load waku
→ Token from env var overrides the one in client/config.json's token_file
→ Useful when client/ isn't writable (remote, containers, CI)
Token Resolution Order
When a client command runs, the token is resolved in this order (first match wins):
| Priority | Source | Example |
|---|---|---|
| 1 | LOGOSCTL_TOKEN env var |
LOGOSCTL_TOKEN=abc123 logosctl module ls |
| 2 | <configDir>/client/<token_file> |
the path is whatever client/config.json says (defaults to auto.json) |
A named token issued by logosctl token issue --name alice produces
<configDir>/daemon/tokens/alice.json on the daemon host. To use it as a
client on a different machine, copy the file into the client host's
<configDir>/client/ and reference it via token_file in client/config.json.
Once copied, the operator may delete the daemon-side raw file — validation
keeps working because the hash is what the daemon checks.
Obtaining a Token
Local usage (same machine): No manual token management needed. At boot
the daemon auto-issues an auto token (with local_only=true, so it can't
be used over TCP), writes the hash into daemon/tokens.json["tokens"], and
emits the raw value into client/auto.json alongside a local-default
client/config.json. Local client commands just work.
Remote or programmatic usage: Issue a named token on the daemon host and move it to the client host:
# On the machine running the daemon:
logosctl token issue --name alice
cat ~/.logosctl/daemon/tokens/alice.json
# Output: 550e8400-e29b-41d4-a716-446655440000
# On the remote machine or in a script:
export LOGOSCTL_TOKEN=550e8400-e29b-41d4-a716-446655440000
logosctl module ls --json
# Or persist by copying the file alongside a hand-written client/config.json:
mkdir -p ~/.logosctl/client
scp daemon-host:~/.logosctl/daemon/tokens/alice.json ~/.logosctl/client/alice.json
# then edit ~/.logosctl/client/config.json so token_file = "alice.json"
CI / containers: Pass the token as an environment variable at runtime:
docker run -e LOGOSCTL_TOKEN=$TOKEN myimage logosctl module ls --json
Daemon files (config / state / tokens)
The daemon dir splits by lifetime into three files:
daemon/state.json— live runtime state. Written every boot (after transports actually bind), removed on clean shutdown.daemon/config.json— operator preferences. Written ONLY when the operator passed--persist-config; otherwise absent.daemon/tokens.json— hashed-at-rest accepted-token list. Independent of the running daemon's lifetime.
daemon/state.json
{
"version": 2,
"instance_id": "a3f1c8d20b4e",
"pid": 12345,
"started_at": "2026-03-23T14:00:00Z",
"config_source": "cli",
"resolved": {
"modules_dirs": ["/path/to/modules"],
"persistence_path": "/var/lib/logosctl",
"modules": {
"core_service": {
"transports": [
{ "protocol": "local" },
{ "protocol": "tcp", "host": "0.0.0.0", "port": 6000, "codec": "json" },
{ "protocol": "tcp_ssl", "host": "0.0.0.0", "port": 6443,
"codec": "cbor", "ca_file": "/etc/logosctl/ca.pem",
"verify_peer": true }
]
},
"capability_module": {
"transports": [
{ "protocol": "local" },
{ "protocol": "tcp", "host": "127.0.0.1", "port": 6001, "codec": "json" }
]
}
},
"ssl": { "cert": "", "key": "", "ca": "" },
"insecure_tcp": false
}
}
instance_idis a 12-char UUID prefix the client uses withLogosInstance::id()to reconstruct the same registry URL the daemon published (local:logos_core_service_<id>).pidlets co-resident clients detect a stale state file (kill(pid, 0) == ESRCHafter a hard crash).config_sourcerecords where the running daemon's config came from:cli(any--module-transport/--insecure-tcp/etc. flag was passed),config.json(loaded from disk only), ordefaults.resolved.modulesis the post-bind transport set:port: 0in config.json becomes the actually-bound port here.resolvedmirrors the shape ofdaemon/config.json(same field set, minusversion).
daemon/config.json (operator preferences)
Same shape as state.json's resolved block, plus version. Reflects
operator intent — port: 0 stays 0 (auto-pick) — not the resolved
post-bind values. Written only when --persist-config is passed.
daemon/tokens.json
{
"version": 2,
"tokens": [
{ "name": "auto", "hash": "<sha256-hex>", "issued_at": "...", "expires_at": null, "local_only": true },
{ "name": "alice", "hash": "<sha256-hex>", "issued_at": "...", "expires_at": "...", "local_only": false }
]
}
One entry per issued token: {name, hash, issued_at, expires_at, local_only}. Hashes are SHA-256 hex; raw values live only in
daemon/tokens/<name>.json at issue time. Independent of the running
daemon's lifetime — survives restarts.
These three files are daemon-owned; the client never reads
config.json or tokens.json, and only consults state.json for a
fast same-host liveness check via kill(pid, 0). The client reads
<configDir>/client/config.json to learn how to dial. Liveness — is
the daemon actually answering? — falls through to the first RPC (e.g.
status), so a connect failure surfaces via the same code path as any
other method call.
Output Design
Every command produces output in one of two modes: human (default when stdout is a TTY) or JSON (when --json is passed or stdout is piped/redirected).
load-module
Human:
$ logosctl module load waku
Loaded module: waku (v0.1.0)
Dependencies loaded: store
JSON:
$ logosctl module load waku --json
{"status":"ok","module":"waku","version":"0.1.0","dependencies_loaded":["store"]}
Error (human):
$ logosctl module load nonexistent
Error: Module 'nonexistent' not found.
Known modules: waku, chat, delivery, store
Scan additional directories with: logosctl daemon start -m /path/to/modules
Error (JSON):
$ logosctl module load nonexistent --json
{"status":"error","code":"MODULE_NOT_FOUND","message":"Module 'nonexistent' not found.","known_modules":["waku","chat","delivery","store"]}
unload-module
Human:
$ logosctl module unload waku
Unloaded module: waku
JSON:
$ logosctl module unload waku --json
{"status":"ok","module":"waku"}
list-modules
Human:
$ logosctl module ls
NAME VERSION STATUS UPTIME
waku v0.1.0 loaded 2h 14m
chat v0.2.0 crashed -
delivery v0.1.0 not loaded -
store v0.3.0 loaded 2h 14m
$ logosctl module ls --loaded
NAME VERSION STATUS UPTIME
waku v0.1.0 loaded 2h 14m
store v0.3.0 loaded 2h 14m
JSON:
$ logosctl module ls --json
[
{"name":"waku","version":"0.1.0","status":"loaded","uptime_seconds":8040},
{"name":"chat","version":"0.2.0","status":"crashed","exit_code":139,"crashed_at":"2026-03-23T14:22:01Z","crash_reason":"SIGSEGV"},
{"name":"delivery","version":"0.1.0","status":"not_loaded"},
{"name":"store","version":"0.3.0","status":"loaded","uptime_seconds":8040}
]
Note: the status field is an enum of loaded | not_loaded | crashed | loading. Crash metadata (exit_code, crashed_at, crash_reason) only appears when status is crashed — the JSON doesn't bloat clean entries with null crash fields.
status
Human:
$ logosctl daemon status
Logosctl Daemon
Status: running
PID: 12847
Uptime: 4h 32m
Version: v0.5.0
Instance ID: a3f1...c8d2
State file: /Users/iuri/.logosctl/daemon/state.json
Modules: 3 loaded, 1 crashed, 1 not loaded
waku v0.1.0 loaded 2h 14m
chat v0.2.0 crashed -
delivery v0.1.0 not loaded -
store v0.3.0 loaded 4h 32m
payments v0.1.0 loaded 4h 32m
JSON:
$ logosctl daemon status --json
{
"daemon": {
"status": "running",
"pid": 12847,
"version": "0.5.0"
},
"modules_summary": {
"loaded": 3,
"crashed": 1,
"not_loaded": 1
},
"modules": [
{"name":"waku","version":"0.1.0","status":"loaded","uptime_seconds":8040},
{"name":"chat","version":"0.2.0","status":"crashed","exit_code":139,"crashed_at":"2026-03-23T14:22:01Z"},
{"name":"delivery","version":"0.1.0","status":"not_loaded"},
{"name":"store","version":"0.3.0","status":"loaded","uptime_seconds":16320},
{"name":"payments","version":"0.1.0","status":"loaded","uptime_seconds":16320}
]
}
When daemon is not running:
$ logosctl daemon status
Logosctl Daemon
Status: not running
No daemon state file at /Users/iuri/.logosctl/daemon/state.json
Run "logosctl daemon start" to start the daemon.
$ echo $?
1
$ logosctl daemon status --json
{
"daemon": {
"status": "not_running"
}
}
$ echo $?
1
reload-module
Human:
$ logosctl module reload chat
Unloading chat... done
Loading chat... done
Module "chat" reloaded successfully (v0.2.0, pid 51203)
JSON:
$ logosctl module reload chat --json
{
"action": "reload",
"module": "chat",
"version": "0.2.0",
"status": "loaded",
"pid": 51203,
"previous_status": "crashed",
"duration_ms": 340
}
When reload fails:
$ logosctl module reload chat
Unloading chat... done
Loading chat... failed
Error: module "chat" failed to start (exit code 1)
Last log: "Config file not found: /etc/logosctl/chat.toml"
Run "logosctl module-logs chat --tail 20" for details.
$ echo $?
3
$ logosctl module reload chat --json
{
"action": "reload",
"module": "chat",
"status": "error",
"error": "module failed to start",
"exit_code": 1,
"last_log_line": "Config file not found: /etc/logosctl/chat.toml"
}
$ echo $?
3
Reload a module that isn't loaded (behaves like load):
$ logosctl module reload delivery
Module "delivery" is not loaded. Loading...
Loading delivery... done
Module "delivery" loaded successfully (v0.1.0, pid 51210)
module-info
Human:
$ logosctl module show chat
Name: chat
Version: v0.2.0
Status: loaded
PID: 23457
Uptime: 2h 14m
Dependencies: waku, store
Methods:
send_message(text: QString) -> QString
Sends a chat message to the active channel.
get_history() -> QJsonArray
Returns the message history for the active channel.
set_nickname(name: QString) -> bool
get_status() -> QString
Events:
message_received(from: QString, body: QString)
Emitted when a new message arrives on the active channel.
connection_changed(online: bool)
Each method line shows name(param: type, …) -> returnType. When a method
carries documentation, its description is printed on the following line(s),
indented — a multi-line doc comment keeps its line breaks, one indented line
each. The description originates from the doc comment written directly above the
method's declaration in the module's header (see the module-builder docs);
methods without a doc comment simply omit it.
The Events section lists the events the module emits, in the same
name(param: type, …) form — but with no return type, since events are
fire-and-forget. An event's description (from the doc comment above its
logos_events: declaration) is printed indented beneath it, exactly as for
methods. The section is omitted when the module declares no events.
Crashed module:
$ logosctl module show chat
Name: chat
Version: v0.2.0
Status: crashed
Exit Code: 139 (SIGSEGV)
Crashed At: 2026-03-23T14:22:01Z
Restart Count: 3
Last Log: "Segmentation fault in message_handler.cpp:142"
JSON:
$ logosctl module show chat --json
{
"name": "chat",
"version": "0.2.0",
"status": "loaded",
"pid": 23457,
"uptime_seconds": 8040,
"dependencies": ["waku", "store"],
"methods": [
{"name": "send_message", "signature": "send_message(QString)", "returnType": "QString", "isInvokable": true, "description": "Sends a chat message to the active channel.", "parameters": [{"name": "text", "type": "QString"}]},
{"name": "get_history", "signature": "get_history()", "returnType": "QJsonArray", "isInvokable": true, "description": "Returns the message history for the active channel.", "parameters": []},
{"name": "set_nickname", "signature": "set_nickname(QString)", "returnType": "bool", "isInvokable": true, "parameters": [{"name": "name", "type": "QString"}]},
{"name": "get_status", "signature": "get_status()", "returnType": "QString", "isInvokable": true, "parameters": []}
],
"events": [
{"name": "message_received", "signature": "message_received(QString,QString)", "description": "Emitted when a new message arrives on the active channel.", "parameters": [{"name": "from", "type": "QString"}, {"name": "body", "type": "QString"}]},
{"name": "connection_changed", "signature": "connection_changed(bool)", "parameters": [{"name": "online", "type": "bool"}]}
]
}
The methods array is the module's getPluginMethods introspection, emitted
verbatim. Each entry carries name, signature, returnType, isInvokable,
parameters (each {name, type}), and — when the method is documented —
description (sourced from the method's header doc comment).
The events array is the module's getPluginEvents introspection. Each entry
carries name, signature, parameters (each {name, type}), and — when the
event is documented — description. There is no returnType/isInvokable:
events are void. Modules with no declared events report an empty array — legacy
Q_INVOKABLE modules (interface: "legacy") always do, since they have no
logos_events: section for the introspection to read. (This used to say
"legacy provider modules"; interface: "provider" is no longer a buildable
module kind — the module builder refuses it and points at
interface: "universal".)
Crashed module (JSON):
$ logosctl module show chat --json
{
"name": "chat",
"version": "0.2.0",
"status": "crashed",
"exit_code": 139,
"crash_signal": "SIGSEGV",
"crashed_at": "2026-03-23T14:22:01Z",
"restart_count": 3,
"last_log_line": "Segmentation fault in message_handler.cpp:142",
"pid_before_crash": 48291
}
call
Human:
$ logosctl call chat send_message "hello world"
message sent (id: msg_4a7b2c)
$ logosctl call math add 2 3
5
In human mode, scalar results (strings, numbers, booleans) are printed as plain values. Structured results (objects, arrays) are printed as indented JSON. Null results produce no output.
JSON:
$ logosctl call chat send_message "hello world" --json
{"status":"ok","module":"chat","method":"send_message","result":"message sent (id: msg_4a7b2c)"}
When the method returns structured data:
$ logosctl call chat get_history --json
{"status":"ok","module":"chat","method":"get_history","result":[{"id":"msg_4a7b2c","from":"alice","text":"hello","timestamp":"2026-03-23T14:30:01Z"},{"id":"msg_5d8e3f","from":"bob","text":"hi there","timestamp":"2026-03-23T14:30:05Z"}]}
LogosResult return values:
Methods declared to return LogosResult (the common ok/error wrapper) are
serialised as:
{"success": <bool>, "value": <any>, "error": <any>}
value is whatever the method stuffed in on success; error is whatever it
stuffed in on failure; the unused side is null. Same shape regardless of
whether the daemon-module hop went over the local socket (QRO), TCP, or
TCP+SSL — pick the transport you like, assertions stay identical.
$ logosctl call account create_account --json
{"status":"ok","module":"account","method":"create_account",
"result":{"success":true,"value":{"id":"42","name":"alice"},"error":null}}
$ logosctl call account create_account --json # duplicate name
{"status":"ok","module":"account","method":"create_account",
"result":{"success":false,"value":null,"error":"name already taken"}}
Error (human):
$ logosctl call chat nonexistent_method
Error: Method 'nonexistent_method' not found on module 'chat'.
Available methods: send_message, get_history, set_nickname, get_status
Error (JSON):
$ logosctl call chat nonexistent_method --json
{"status":"error","code":"METHOD_NOT_FOUND","message":"Method 'nonexistent_method' not found on module 'chat'.","available_methods":["send_message","get_history","set_nickname","get_status"]}
Timeout error (JSON):
$ logosctl call chat slow_operation --json
{"status":"error","code":"TIMEOUT","message":"Call to chat.slow_operation timed out after 30s."}
watch
Streams continuously until interrupted. Each event is printed as it arrives.
Human:
$ logosctl watch chat --event chat-message
[14:30:01] chat :: chat-message
from: alice
text: hello world
[14:30:05] chat :: chat-message
from: bob
text: hi there
[14:31:12] chat :: chat-message
from: alice
text: how are you?
^C
JSON (NDJSON — one self-contained JSON object per line):
$ logosctl watch chat --event chat-message --json
{"timestamp":"2026-03-23T14:30:01Z","module":"chat","event":"chat-message","data":{"from":"alice","text":"hello world"}}
{"timestamp":"2026-03-23T14:30:05Z","module":"chat","event":"chat-message","data":{"from":"bob","text":"hi there"}}
{"timestamp":"2026-03-23T14:31:12Z","module":"chat","event":"chat-message","data":{"from":"alice","text":"how are you?"}}
All events from a module (no --event filter):
$ logosctl watch chat --json
{"timestamp":"2026-03-23T14:30:01Z","module":"chat","event":"chat-message","data":{"from":"alice","text":"hello"}}
{"timestamp":"2026-03-23T14:30:02Z","module":"chat","event":"user-joined","data":{"user":"bob"}}
{"timestamp":"2026-03-23T14:30:05Z","module":"chat","event":"chat-message","data":{"from":"bob","text":"hi"}}
{"timestamp":"2026-03-23T14:30:06Z","module":"chat","event":"typing","data":{"user":"alice"}}
stats
Human:
$ logosctl stats
MODULE PID CPU% MEMORY
waku 23456 2.1% 48.3 MB
chat 23457 0.4% 22.1 MB
store 23458 0.1% 15.7 MB
JSON:
$ logosctl stats --json
[
{"name":"waku","pid":23456,"cpu_percent":2.1,"memory_mb":48.3},
{"name":"chat","pid":23457,"cpu_percent":0.4,"memory_mb":22.1},
{"name":"store","pid":23458,"cpu_percent":0.1,"memory_mb":15.7}
]
info
Alias for module-info. See the module-info output section above for all output examples including human, JSON, and crashed module variants.
No daemon running
Human:
$ logosctl module ls
Error: No running logosctl daemon.
Start one with: logosctl daemon start
Start with modules: logosctl daemon start -m /path/to/modules
JSON:
$ logosctl module ls --json
{"status":"error","code":"NO_DAEMON","message":"No running logosctl daemon. Start one with: logosctl daemon start"}
Output Rules
- Primary output (results, data) goes to stdout.
- Debug, info, and warning logs go to stderr and are suppressed by default. Pass
--verboseto show them. - Critical and fatal errors always go to stderr.
- In JSON mode, colors are disabled and only structured data goes to stdout.
- JSON mode auto-activates when stdout is not a TTY (piped or redirected), so agents and scripts get JSON by default without needing
--json. - Daemon startup/shutdown messages go to stdout, so
logosctl daemon start > logs.txtcaptures them correctly.
Error Handling
Exit Codes
| Code | Meaning | When |
|---|---|---|
0 |
Success | Operation completed |
1 |
General error | Unexpected failure, invalid arguments |
2 |
Connection error | No daemon running, daemon unreachable |
3 |
Module error | Module not found, failed to load/unload |
4 |
Method error | Method not found, invocation failed, timeout |
JSON Error Envelope
All errors in JSON mode follow this structure:
{
"status": "error",
"code": "ERROR_CODE",
"message": "Human-readable description with recovery suggestion."
}
Error codes: NO_DAEMON, DAEMON_UNREACHABLE, MODULE_NOT_FOUND, MODULE_LOAD_FAILED, MODULE_NOT_LOADED, METHOD_NOT_FOUND, METHOD_FAILED, TIMEOUT, AUTH_FAILED, INVALID_ARGS.
Daemon + client workflow
Module method calls go through a running daemon. Start a clean daemon with
-D (it loads no modules on its own), then load modules and call methods with
client subcommands:
# Start a clean daemon scanning /path
logosctl daemon start -m /path &
logosctl module load waku # deps resolved automatically
logosctl module load chat
logosctl call chat send_message "hello"
The legacy inline mode (-c "module.method(args)" / --quit-on-finish, which
started the core, ran calls in one short-lived process, and exited) has been
removed, as has -l/--load-modules (the daemon now starts clean — load via
load-module). -m/--persistence-path apply only to daemon startup (-D);
a subcommand operates in client mode and connects to a running daemon.
AI Agent Workflow
This section describes how an AI agent (such as Claude Code, Cursor, or similar tools that execute bash commands via a tool-use interface) would interact with the logosctl CLI.
How Agents Use This CLI
AI agents interact with CLIs by executing bash commands and parsing stdout. They cannot handle interactive prompts, colored output, or ambiguous formatting. The logosctl CLI is designed for this:
- JSON by default when piped. Since agents capture stdout programmatically (not via a TTY), JSON mode activates automatically. No need to remember
--json. - Deterministic exit codes. Agents check
$?after each command to decide whether to proceed or handle an error. Each error category has a distinct code. - Structured errors. When something fails, the JSON error includes a
codefield the agent can branch on, and amessagefield with recovery instructions the agent can follow. - No interactive prompts. Every operation completes without requiring user input.
- Self-describing.
logosctl module show <module> --jsontells the agent what methods are available and what parameters they take, without needing external documentation.
Example: Agent Preflight — Health Check Before Doing Work
Before performing any operation, an agent checks daemon health and ensures required modules are running:
# Step 1: Is the daemon alive?
if ! logosctl daemon status --json | jq -e '.daemon.status == "running"' > /dev/null 2>&1; then
echo "daemon not running, starting..."
logosctl daemon start --detach &
sleep 2
fi
# Step 2: Check if the module I need is healthy
MODULE_STATUS=$(logosctl daemon status --json | jq -r '.modules[] | select(.name=="chat") | .status')
case "$MODULE_STATUS" in
"loaded") echo "ready" ;;
"crashed") logosctl module reload chat ;;
"not_loaded") logosctl module load chat ;;
*) echo "unknown state: $MODULE_STATUS" ; exit 1 ;;
esac
Example: Agent Detects and Recovers a Crashed Module
# Agent checks module health
STATUS=$(logosctl module ls --json | jq -r '.[] | select(.name=="chat") | .status')
if [ "$STATUS" = "crashed" ]; then
# Get crash details for decision-making
CRASH_INFO=$(logosctl module show chat --json)
RESTARTS=$(echo "$CRASH_INFO" | jq '.restart_count')
if [ "$RESTARTS" -lt 5 ]; then
logosctl module reload chat
else
echo "chat module crashed $RESTARTS times, escalating"
# agent decides to alert or investigate logs
logosctl module-logs chat --tail 50
fi
fi
Example: Agent Builds and Tests a Chat Application
This is a realistic sequence an AI agent would execute when asked to "set up and test the chat module":
# Step 1: Start the daemon and verify it's running
logosctl daemon start --detach &
sleep 2
logosctl daemon status --json | jq -e '.daemon.status == "running"' > /dev/null
# Agent confirms daemon is up via exit code 0.
# Step 2: Check what modules are available
logosctl module ls --json
# Agent parses:
# [
# {"name":"waku","version":"0.1.0","status":"not_loaded"},
# {"name":"chat","version":"0.2.0","status":"not_loaded"},
# {"name":"store","version":"0.3.0","status":"not_loaded"}
# ]
# Agent reads the array and identifies "chat" is available.
# Step 3: Load the chat module
logosctl module load chat
# Agent parses:
# {"status":"ok","module":"chat","version":"0.2.0","dependencies_loaded":["waku","store"]}
# Agent confirms status is "ok" and notes that waku and store were auto-loaded.
# Step 4: Discover what methods are available
logosctl module show chat --json
# Agent parses:
# {
# "name": "chat",
# "version": "0.2.0",
# "status": "loaded",
# "pid": 23457,
# "uptime_seconds": 5,
# "dependencies": ["waku", "store"],
# "methods": [
# {"name": "send_message", "signature": "send_message(QString)", "returnType": "QString", "isInvokable": true, "description": "Sends a chat message to the active channel.", "parameters": [{"name": "text", "type": "QString"}]},
# {"name": "get_history", "signature": "get_history()", "returnType": "QJsonArray", "isInvokable": true, "description": "Returns the message history for the active channel.", "parameters": []},
# {"name": "get_status", "signature": "get_status()", "returnType": "QString", "isInvokable": true, "parameters": []}
# ],
# "events": [
# {"name": "message_received", "signature": "message_received(QString,QString)", "description": "Emitted when a new message arrives on the active channel.", "parameters": [{"name": "from", "type": "QString"}, {"name": "body", "type": "QString"}]}
# ]
# }
# Agent now knows send_message takes a text param and returns a string, and —
# from each method's "description" — what it does, without any external docs.
# The "events" array tells it which events it can watch (and what they mean).
# Step 5: Call a method
logosctl call chat send_message "hello from agent"
# Agent parses:
# {"status":"ok","module":"chat","method":"send_message","result":"message sent (id: msg_9x8y7z)"}
# Agent confirms status is "ok".
# Step 6: Verify the message was stored
logosctl call chat get_history
# Agent parses:
# {"status":"ok","module":"chat","method":"get_history","result":[{"id":"msg_9x8y7z","from":"agent","text":"hello from agent","timestamp":"2026-03-23T14:30:01Z"}]}
# Agent verifies the message appears in history.
# Step 7: Check overall system health
logosctl daemon status --json | jq '.modules_summary'
# Agent parses:
# {"loaded": 3, "crashed": 0, "not_loaded": 0}
# All modules healthy. Agent can also check per-module resource usage via `logosctl stats`.
Example: Agent Handles Errors
When an agent encounters an error, the structured output lets it self-correct:
# Agent tries to call a method on a module that isn't loaded
logosctl call delivery send_package "pkg_123"
# Exit code: 3
# {"status":"error","code":"MODULE_NOT_LOADED","message":"Module 'delivery' is not loaded. Load it with: logosctl module load delivery"}
# Agent reads the error code "MODULE_NOT_LOADED" and the recovery instruction.
# It follows the suggestion:
logosctl module load delivery
# {"status":"ok","module":"delivery","version":"0.1.0","dependencies_loaded":[]}
# Now retries the original call:
logosctl call delivery send_package "pkg_123"
# {"status":"ok","module":"delivery","method":"send_package","result":"package pkg_123 queued"}
Example: Agent Monitors Events
An agent can watch for events to react to real-time activity:
# Start watching in background, capture output to a file
logosctl watch chat --event chat-message > /tmp/chat_events.log &
WATCH_PID=$!
# ... agent does other work ...
# Later, check what events arrived
cat /tmp/chat_events.log
# {"timestamp":"2026-03-23T14:30:01Z","module":"chat","event":"chat-message","data":{"from":"alice","text":"hello"}}
# {"timestamp":"2026-03-23T14:30:05Z","module":"chat","event":"chat-message","data":{"from":"bob","text":"hi there"}}
# Agent can parse each line independently (NDJSON).
# Each line is valid JSON, so standard tools work:
# cat /tmp/chat_events.log | head -1 | jq '.data.from'
# → "alice"
# Cleanup
kill $WATCH_PID
Why These Patterns Matter for Agents
| Pattern | Why it helps agents |
|---|---|
| JSON auto-detection (non-TTY) | Agent doesn't need to remember --json — it gets structured output automatically |
| Exit codes per error category | Agent can branch: if exit_code == 2, start daemon; if exit_code == 3, load module |
| Error messages with recovery commands | Agent can extract and execute the suggested fix directly |
status as single dashboard |
One command gives daemon health + all module states — no need to chain multiple commands |
module-info with method signatures + descriptions |
Agent discovers available operations and their intent — it reads each method's schema and description to construct calls without external docs |
module-info with crash metadata |
Agent can programmatically distinguish OOM (137/SIGKILL) from segfault (139/SIGSEGV) from clean error (non-zero) |
reload-module on unloaded module |
Falls back to load instead of erroring — reduces edge cases for agents that just want a module running |
| NDJSON streaming | Agent processes events line-by-line without buffering the full stream |
| No interactive prompts | Agent never hangs waiting for input it can't provide |
Consistent JSON envelope (status, code) |
Agent uses the same parsing logic for all commands |
Sequence Flows
Starting the Daemon and Loading Modules
1. START DAEMON
logosctl daemon start -m /path/to/modules
→ Core initializes
→ Daemon mints "auto" token (local_only=true) for the local client
→ Scans /path/to/modules for available plugins
→ Writes ~/.logosctl/daemon/state.json (instance + resolved listeners)
→ Writes ~/.logosctl/daemon/tokens.json (hashed accepted-token list)
→ Emits ~/.logosctl/client/config.json + ~/.logosctl/client/auto.json
→ Runs event loop (foreground)
2. LOAD MODULES
logosctl module load waku
→ Reads ~/.logosctl/client/config.json (dial spec + token_file)
→ Loads token from the file token_file points at
→ Connects to daemon's `core_service` via RPC with token
→ Daemon resolves dependencies for "waku"
→ Daemon loads dependencies first, then waku
→ Client prints result and exits
3. CALL METHODS
logosctl call chat send_message "hello"
→ Reads dial spec + token from ~/.logosctl/client/
→ Connects to daemon
→ Invokes chat.send_message("hello") via RPC
→ Prints return value to stdout
→ Exits
4. WATCH EVENTS
logosctl watch chat --event chat-message --json >> events.log &
→ Connects to daemon with token
→ Registers event listener for chat::chat-message
→ Streams NDJSON to stdout (redirected to events.log)
→ Runs until killed
5. STOP DAEMON
logosctl daemon stop
→ Client sends shutdown RPC to core_service
→ Daemon schedules quit (with brief delay to send RPC response)
→ Daemon unloads all modules
→ Removes ~/.logosctl/daemon/state.json (tokens.json + config.json survive)
→ Exits
Alternatively: Ctrl+C / kill <pid> / SIGTERM
→ Signal handler triggers QCoreApplication::quit()
→ Same cleanup as above