Files
logos-logoscore-cli/docs/logosctl.md
Dario LipicarandClaude Opus 5 0b2afed18a chore(deps): track master for protocol, cpp-sdk and plugin-qt (#92)
* 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>
2026-08-20 12:47:31 -03:00

26 KiB

logosctl

The merged CLI: logoscore + lgpd + lgpm in one tool. It runs modules like logoscore does, and it manages packages itself — bundling the package_manager and package_downloader modules, so searching a catalog, installing with dependencies resolved, loading and calling are all one binary's job.

Being validated; not yet the default. It shares the runtime with logoscore but no state — its session lives in ~/.logosctl, so nothing here can disturb a logoscore deployment.

Build instructions, flake outputs and test targets are in the main README. The .#ctl-* outputs are the ones that ship this binary; the package commands need a portable build, because that is what the public catalog ships.

Usage

logosctl runs as a daemon (long-running process) that you drive with client commands to load modules and call methods.

Daemon Mode

Start a daemon, then use client commands to manage modules and call methods.

Sessions

Everything logosctl needs lives in one directory — a session. --config-dir picks which one (default ~/.logosctl, also LOGOSCTL_CONFIG_DIR):

<session>/
├── daemon/
│   ├── config.yaml     # daemon configuration — you write this
│   ├── startup.err     # transient: a --detach child's output before logging is up
│   ├── state.json      # live instance: pid, instance id, bound ports
│   ├── tokens.json     # hashed-at-rest accepted tokens
│   └── tokens/<name>.json
├── client/
│   ├── config.yaml     # dial spec + token_file — you write this for remote use
│   └── auto.json       # local token, rewritten by the daemon each boot
├── modules/            # core modules installed into this session
├── plugins/            # UI plugins installed into this session
├── keyring/            # trusted package-signing keys
├── logs/               # daemon.log -> daemon_<timestamp>.log, rotated
├── cache/downloads/    # fetched .lgx
└── data/               # per-module persistence

A session is portable by default: copy the directory and its packages, catalogs and trust assumptions come with it. Two sessions can hold different package sets and disagree about which signers they trust. The only things left outside are the binary itself and the runtime sockets in $TMPDIR.

That default is not a cage — any of the subdirectories can be redirected:

dirs:
  keyring: ~/.config/logos/trusted-keys   # share trust across sessions
  cache: /var/cache/logos                 # put downloads on a bigger disk
  modules: /opt/logos/modules             # a tree something else manages
  plugins: plugins-custom                 # relative -> stays inside the session
  data: /var/lib/logos/data
  logs: /var/log/logos                    # ship logs where your collector looks

How a value is written decides whether portability survives:

Form Resolves to
plugins-custom <session>/plugins-custom still portable
~/x $HOME/x outside the session
/var/cache/logos as given outside the session

persistence_path is the older spelling of dirs.data and still works; dirs.data wins if both are set.

Modules come from two places: the read-only set bundled beside the binary (capability_module, package_manager, package_downloader) and the writable <session>/modules. On a name collision the session's copy wins, which is how you override a bundled module.

Starting the Daemon

logosctl daemon start              # runs in the foreground
logosctl daemon start --detach     # forks, returns once it is accepting commands
logosctl daemon status
logosctl daemon stop

--detach is not the same as &: backgrounding returns immediately, before the transports have bound, so the next command races the boot. --detach returns only after the daemon has published daemon/state.json, and tells you where it is logging.

Logs

Everything the daemon and its module subprocesses write goes to a rotating file under logs/:

logging:
  enabled: true          # false -> no log file at all
  file: daemon.log       # inside dirs.logs
  max_size_mb: 10        # rotate past this; 0 = never rotate
  max_files: 5           # keep this many in total, oldest dropped
  console: true          # mirror to the terminal (ignored once detached)

Each start writes a new, timestamped file, the same scheme Basecamp uses:

logs/
├── daemon.log -> daemon_20260729_180411.log   # always the current session
├── daemon_20260729_180404.log
├── daemon_20260729_180409.log
└── daemon_20260729_180411.log

logging.file names the log; the start time is inserted into the real file, and that exact name survives as a symlink to whichever file is current — so tail -F logs/daemon.log follows across restarts without anyone working out a stamp.

max_files bounds the directory, not just one session's rotations: the oldest files are pruned at each start, so a daemon restarted a hundred times does not leave a hundred logs. (spdlog's own retention only prunes within a single sink's rotation set, which is why this is enforced separately.)

Capture is pipe-based, not a file redirect, and that is the point: module hosts are separate processes holding inherited descriptors. Redirecting to a file would catch their output but make rotation impossible — renaming a file out from under a child that has it open just keeps filling the old inode. A pipe puts one reader in charge, so rotation is safe and subprocess output still lands in the log.

The size cap and retention come from spdlog's rotating sink, which liblogos already logs through. Lines arriving from the pipe already carry their own timestamp and level, so they are written verbatim rather than stamped twice.

Configuration

Configuration is a YAML document you install into the session. It is never passed alongside another command, so daemon start and every client command act on the session exactly as it is on disk.

logosctl daemon config set ./node.yaml     # also accepts @file, or - for stdin
logosctl daemon config show

set replaces the file wholesale — there is no merge — and validates the whole document, through the loader the daemon itself uses, before writing anything. A document it rejects is never installed, so the previous config stays intact and the session is never left holding one the daemon would refuse to boot from. Unknown keys are rejected by name, because the loader ignores what it does not recognise and a silently-dropped insecureTcp (the key is insecure_tcp) would leave the daemon running with your intent missing. A key whose value is the wrong type is reported the same way — modules_dirs: /single/path (a scalar where a list belongs) fails with modules_dirs: expected a list of strings, but got a string. rather than being ignored, half-applied, or fatal.

# node.yaml
insecure_tcp: false
dirs:                        # optional; each defaults to <session>/<name>
  keyring: ~/.config/logos/trusted-keys
access_group: logos          # share the daemon with an OS group (see below)
signature_policy: warn       # none | warn | require
modules_dirs:                # extra read-only module directories to scan
  - /opt/logos/modules
ssl:                         # default TLS material for every tcp_ssl listener
  cert: /etc/logos/tls/server.pem
  key:  /etc/logos/tls/server.key
modules:
  core_service:
    - protocol: tcp_ssl
      host: 0.0.0.0
      port: 8645
      codec: json            # json | cbor
  capability_module:
    - protocol: tcp_ssl
      host: 0.0.0.0
      port: 8646
      cert: /etc/logos/tls/other.pem   # overrides the ssl: block for this one
      key:  /etc/logos/tls/other.key

Per-listener keys are protocol, host, port, codec, cert, key, ca_file and verify_peer. cert, key and ca_file are opened as written — unlike dirs:, a relative one resolves against the daemon process's working directory, not the session, so the same config started from a different directory silently fails its TLS handshake (use_certificate_chain_file: No such file or directory). Give them absolute paths.

The top-level ssl: { cert, key, ca } block is the default for every tcp_ssl listener in the document — the usual case, where one certificate covers the whole node. A listener that names its own cert:/key:/ca_file: keeps it; the merge is per field, so a listener that names only a cert: still inherits the block's key:. A tcp_ssl listener left with no certificate from either source is refused at startup rather than bound: it would accept connections and fail every handshake with no shared cipher, which reads like a client fault.

signature_policy: is handed to the session's package manager at daemon start and decides what logosctl package install does with an unsigned package or one signed by a key that is not in the session's keyring/: none skips the check, warn (the default when the key is absent) installs and prints a warning, require refuses the install. It takes effect on the next daemon start, like everything else in this file.

If the value cannot be delivered to the package manager at startup — the module did not load, or its object could not be acquired — the daemon unloads the package manager rather than leave it enforcing its own warn default while this file, logosctl config get and state.json all still say require. Package commands are unavailable for that session, and the reason is on stderr. A failure to set the directories is not treated the same way: every directory fails closed on its own (an install refuses with User modules directory is not set), so that case warns and leaves the manager up.

A local listener is always added to every module, so same-host clients and the daemon's own cross-module calls keep working whatever else you configure. Any tcp/tcp_ssl entries are additional, outward-facing listeners.

Remote clients need capability_module exposed too, not just core_service. Before its first RPC a client performs a requestModule handshake against capability_module. On the same host that rides the free local listener; from another host it has to reach capability_module over the network. Exposing only core_service is the single most common remote-setup mistake — client commands hang at connect time because the handshake never completes.

Plaintext tcp on a non-loopback host puts tokens on the wire in cleartext. The daemon refuses to bind that unless insecure_tcp: true is set. Prefer tcp_ssl, or a TLS terminator in front.

The client side is symmetric:

logosctl client config set ./client.yaml
logosctl client config show
# client.yaml — only needed to reach a daemon on another host
version: 2
token_file: alice.json       # a bare filename, read from <session>/client/
daemon:
  core_service:      { transport: tcp_ssl, host: node.example, port: 8645, ca: /etc/logos/tls/ca.pem, verify_peer: true }
  capability_module: { transport: tcp_ssl, host: node.example, port: 8646, ca: /etc/logos/tls/ca.pem, verify_peer: true }

The accepted top-level keys are exactly version, daemon, token_file and instance_id. token_file must be a plain filename — anything containing a separator or .. is refused and the client fails closed with "token file not found". ca is opened as written, so give it an absolute path for the same reason cert/key need one on the daemon side.

The two documents describe different ends of the same connection, and their key names were never unified. Per listener:

Daemon (modules:) Client (daemon:)
which protocol protocol: transport:
where host:, port:, codec: host:, port:, codec:
CA to trust ca_file: ca:
verify the peer verify_peer: verify_peer:
own certificate cert:, key: — (no client counterpart)

So ca is the client's spelling of the daemon's ca_file, and cert/key are the daemon's server certificate — a client config has no equivalent. The validator names the key it expected, so a mix-up fails at config set rather than at connect time.

For same-host use you do not need this at all: the daemon writes a working client/config.yaml and client/auto.json into the session on every boot.

The two documents are kept separate on purpose: the daemon never reads client/, and the client never reads daemon/. Only the files above are YAML — everything the daemon and the modules own (state.json, tokens.json, the token files, the downloader's catalog config) stays JSON.

Client Commands

Commands are grouped by what they act on. ls is the list verb throughout, show the detail verb.

# Daemon
logosctl daemon start [--detach]     # start the runtime
logosctl daemon stop                 # graceful shutdown
logosctl daemon status               # health, uptime, module summary
logosctl daemon config set FILE      # install/replace the daemon config
logosctl daemon config show          # print it, and where it lives

# Client dial settings (only needed to reach a daemon on another host)
logosctl client config set FILE
logosctl client config show

# Modules — what is running right now
logosctl module ls [--loaded]        # list known / loaded modules
logosctl module show NAME            # methods, events, deps, crash detail
logosctl module load NAME            # + dependencies, always (no opt-out)
logosctl module unload NAME          # + dependents  (--no-dependents to opt out)
logosctl module reload NAME
logosctl module stats                # per-module CPU / memory

# Calling and watching
logosctl call MODULE METHOD [args...]
logosctl watch MODULE [--event NAME]

# Packages — what is on disk
# install/upgrade share one option set:
#   --file X.lgx  --dir D  --version V  --root-hash H  --catalog C
#   -y|--yes  --dry-run  --no-deps  --no-dependents
# remove takes names only:  -y  --dry-run  --no-dependents
logosctl package install NAME|FILE.lgx ...   # a path installs from disk
logosctl package install --dir D             # every .lgx in a directory
logosctl package upgrade NAME|FILE.lgx
logosctl package remove NAME         # + dependents by default
logosctl package ls [--type core|ui]
logosctl package show NAME|FILE.lgx  # installed detail, or inspect an .lgx
logosctl package deps NAME [-r|--recursive] [--reverse]
logosctl package search [QUERY] [--category C] [--catalog C]
logosctl package download NAME [--version V] [--root-hash H] [--catalog C] [-o|--output DIR]

# Catalogs
logosctl catalog ls
logosctl catalog add URL | remove URL | enable URL | disable URL
logosctl catalog refresh

# Trusted signing keys (per session)
logosctl key ls
logosctl key add NAME --did DID [--display-name N] [--url U]
logosctl key remove NAME

# Auth tokens (offline; operates on the session dir)
logosctl token issue --name N [--expires D] [--replace] [--local-only]
logosctl token ls
logosctl token revoke NAME

Shorthands exist for the things you type most: status, stop, call, watch, stats, install, and search work at the top level. list is accepted wherever ls is, info wherever show is, and uninstall for package remove.

install and search are the only package commands aliased to the top level, because they are the only ones with no runtime-module meaning — ls, show and remove would each be ambiguous between a package and a loaded module.

Two defaults are worth stating plainly, because they are the opposite of what some tools do:

  • install does not load. It puts files on disk; module load activates them. Staging a package without starting it is a thing you may well want.
  • remove takes dependents with it, and unload too. Leaving a module running against something that no longer exists is the more surprising outcome. --no-dependents opts out, acting on the named package or module alone and leaving its dependents in place.
  • module load always resolves dependencies. There is no --no-deps on it — that flag exists only on package install / package upgrade, which are about files on disk, not about what is running.
  • An argument ending in .lgx is a path, not a name. install and upgrade read it as a file on disk and skip the catalog entirely, so a local package installs with no catalog configured and no network. --file says so explicitly and --dir takes every .lgx in a directory. Catalog names and local files cannot be mixed in one command — they resolve by different rules, so run them separately.

Installing does not require a daemon restart: the daemon re-scans afterwards, and only modules that were already running are stopped and restarted.

Argument typing

Each positional argument to call is turned into a JSON value using the first rule that matches, so scalars stay ergonomic while lists, maps, and literal strings are all expressible:

Argument form Becomes Example
json:<value> the value parsed as JSON (list / map / number / any nested value) json:[1,2,3], json:{"k":"v"}
json:@<file> the file's contents parsed as JSON json:@payload.json
str:<text> <text> verbatim as a string — no parsing, no coercion str:json:x"json:x", str:42"42"
@<file> the file's raw contents as a string @config.json
true / false a boolean true
a whole number an integer 42
a decimal number a double 3.14
anything else a string hello

json: and str: are the two explicit escapes, mirroring the convention used by jq (--arg / --argjson) and HTTPie (= / :=): the default path never guesses a container, json: opts into parsing, and str: forces a literal string for any value the default rules would otherwise reinterpret (a number-like string, or one that itself starts with json: / str: / @).

Binary (bstr) arguments. JSON has no native byte type, so bytes use the canonical tagged encoding — a JSON object {"_bytes": "<base64url, unpadded>"}. Pass it like any other JSON value with json::

# base64url("hello") == "aGVsbG8"
logosctl call blobstore put 'json:{"_bytes":"aGVsbG8"}'
logosctl call blobstore put 'json:@blob.json'   # {"_bytes":"..."} from a file

Exit Codes

Code Meaning
0 Success
1 General error / daemon not running (for status)
2 No daemon running
3 Module error (not found, load/unload failed)
4 Method error (not found, call failed, timeout)

Packages

logosctl installs and manages packages itself — it bundles package_manager and package_downloader, the same modules Basecamp uses, so both frontends drive an identical surface. All package commands need a running daemon, because that is where those modules live.

logosctl catalog ls                     # configured catalogs
logosctl catalog add <url>              # add one; also remove/enable/disable
logosctl catalog refresh                # re-fetch every enabled catalog

logosctl search storage                 # search the merged catalog
logosctl install storage_module --dry-run   # show what would change
logosctl install storage_module -y
logosctl package ls                     # what is installed in this session
logosctl package show storage_module    # or a path to an .lgx to inspect it
logosctl package deps storage_module -r
logosctl package upgrade storage_module -y
logosctl package remove storage_module -y

A package you already have on disk needs none of that — no catalog, no network. Give install the path:

logosctl install ./storage_module.lgx -y      # or: --file ./storage_module.lgx
logosctl install --dir ./downloads -y         # every .lgx in a directory
logosctl package show ./storage_module.lgx    # inspect one without installing

install puts files on disk; it does not load anything. Loading is a separate, explicit act:

logosctl install storage_module -y
logosctl module load storage_module

The daemon re-scans after an install, so a freshly installed module is loadable immediately — no restart. What install does restart is anything that was already running and had to be stopped to make way; nothing else is touched.

Dependencies are handled by default in the direction that avoids breakage: install/upgrade pull dependencies in (--no-deps opts out), and remove takes dependents with it (--no-dependents opts out). --dry-run prints the full change table plus which running modules will be stopped, and without -y you are asked to confirm. With no terminal and no -y the operation is refused rather than assumed — a script that forgot --yes should fail loudly.

Package signatures are verified against the session's own keyring:

logosctl key ls
logosctl key add acme --did did:jwk:... --display-name "Acme"
logosctl key remove acme

Because the keyring lives inside the session, two sessions can hold different trust assumptions — and this is deliberately not shared with Basecamp's keyring, so a key trusted in one is not automatically trusted in the other.

Agent / Script Example

# Start daemon (--detach returns only once it is accepting commands,
# so no `&` and no sleep are needed)
logosctl daemon start --detach

# Preflight: verify daemon is running
logosctl daemon status --json | jq -e '.daemon.status == "running"' > /dev/null

# Load modules
logosctl module load chat --json

# Discover available methods (with their documentation)
logosctl module show chat --json | jq '.methods[] | {name, description}'

# Discover the events a module emits (with their documentation)
logosctl module show chat --json | jq '.events[] | {name, description}'

# Call a method
logosctl call chat send_message "hello from script" --json

# Auto-reload any crashed modules
logosctl module ls --json | jq -r '.[] | select(.status == "crashed") | .name' | while read mod; do
  logosctl module reload "$mod" --json
done

# Stream events to a log file
logosctl watch chat --event chat-message --json >> events.log &

Events Example

Modules can emit events that you can listen to in real time. Use watch to subscribe and call to trigger:

# Start the daemon (the modules it scans come from the session's
# modules/ directory and any modules_dirs in daemon/config.yaml)
logosctl daemon start --detach

# Load the module
logosctl module load test_basic_module

# Start watching for events in the background, writing to a file
logosctl watch test_basic_module --event testEvent > events.txt &
WATCH_PID=$!

# Trigger the event from another call
logosctl call test_basic_module emitTestEvent "hello world"

# Check the captured event
cat events.txt

# Clean up
kill $WATCH_PID
logosctl daemon stop

Quick start: load modules and call methods

The daemon starts clean (it scans the module directories but loads nothing on its own). Load modules with module load — transitive dependencies are resolved automatically — then call methods:

# Start a clean daemon on this session's modules
logosctl daemon start --detach

# Load modules (deps resolved automatically)
logosctl module load waku
logosctl module load chat

# Call methods (positional args — see "Argument typing" above)
logosctl call chat send_message hello
logosctl call storage init config 42 true
logosctl call storage loadConfig @config.json           # @file → raw file contents
logosctl call storage setTags 'json:["a","b"]'          # json: → parsed list/map/value
logosctl call storage setLabel 'str:42'                 # str: → literal string "42"

# A throwaway session, isolated from ~/.logosctl
logosctl --config-dir /tmp/test-session daemon start --detach

# Stop the daemon when done
logosctl daemon stop

Daemon startup options:

  -D                    Start the daemon (same as `daemon start`)
  -d, --detach          Fork and return once the daemon is accepting commands
      --config-dir DIR  Which session to run

Everything else — module directories, persistence, transports, the access policy — is configuration, and lives in daemon/config.yaml.

These global flags work before or after any subcommand:

  -h, --help            Show help
      --version         Print the version
  -v, --verbose         Show debug logs
  -j, --json            Force JSON output
      --no-json,--human Force human-readable output even when piped
  -q, --quiet           Suppress non-essential output
      --config-dir DIR  Which session to act on (also LOGOSCTL_CONFIG_DIR)

Access policy

The access_policy key declares, per target module, which caller modules may invoke it. Unlike every other key in this document, its value is a JSON document carried as a string — the daemon hands the text straight to the runtime rather than interpreting it, so it is not modelled as YAML. A YAML block scalar (|) is the readable way to write it:

access_policy: |
  {
    "version": 1,
    "mode": "enforce",
    "restrictions": {
      "package_manager":    { "allowedCallers": ["package_manager_ui"] },
      "package_downloader": { "allowedCallers": ["package_manager_ui"] }
    }
  }

Do not write it as a YAML mapping. access_policy: followed by an indented version: 1 / restrictions: block is read as an object where a string is expected. daemon config set reports it by name (access_policy: expected a string, but got a mapping.) and writes nothing. Quote the JSON, or use the | block above.

The string is handed to the runtime (via logos_core_set_access_policy) before any module is loaded.

Note: the value must be a full policy document. The enforce shorthand is a logoscore flag spelling (--access-policy enforce); here, write the equivalent document — {"version":1,"mode":"enforce","restrictions":{}} — which arms deny-by-default with the allow-lists derived from each module's declared dependencies.

Note: the legacy inline mode (-c "module.method(args)" / --quit-on-finish, which ran calls in a single short-lived process) has been removed. Use a daemon plus logosctl call ... as shown above.