@securecoms/agent-runtime
v0.7.0
Published
A first-party SecureComs agent. It attaches to the local SecureComs daemon and runs a BYO (bring-your-own) OpenAI-compatible LLM against incoming messages, with skills, per-channel memory, and a local tool gate — no AgentVault backend involved.
Readme
@securecoms/agent-runtime
A first-party SecureComs agent. It attaches to the local SecureComs daemon and runs a BYO (bring-your-own) OpenAI-compatible LLM against incoming messages, with skills, per-channel memory, and a local tool gate — no AgentVault backend involved.
Install and run
npm i -g @securecoms/agent-runtime
securecoms-agent --identity <identity_id><identity_id> (or SECURECOMS_IDENTITY) selects which local daemon
connection this process serves.
Running it at login (service install)
securecoms-agent install --identity <identity_id> [--llm-endpoint <url>] \
[--llm-model <model>] [--llm-key-file <path>] [--skills-dir <dir>] \
[--data-dir <dir>] [--brave-key-file <path>] [--tavily-key-file <path>] \
[--skills-config <path>] [--exec-path <node>]
securecoms-agent install --identity <identity_id> --dry-run
securecoms-agent uninstall --identity <identity_id>Every install flag also reads its SECURECOMS_* variable from the environment
when the flag is absent (SECURECOMS_IDENTITY, SECURECOMS_LLM_ENDPOINT,
SECURECOMS_LLM_MODEL, SECURECOMS_LLM_API_KEY_FILE, SECURECOMS_SKILLS_DIR,
SECURECOMS_DATA_DIR, SECURECOMS_BRAVE_KEY_FILE, SECURECOMS_TAVILY_KEY_FILE,
SECURECOMS_SKILLS_CONFIG), so an operator who already exports the run-mode
environment can just run securecoms-agent install. The flag always wins.
--exec-path is the one exception: it is never taken from the environment.
--dry-run prints the resolved Node path, the service file path, the log
path, the key file, the brave/tavily key files and the skills config path —
each as a PATH only, never a key value — and the rendered plist/unit
without writing anything or calling launchctl/systemctl — use it to
inspect an install before making it.
install writes a per-user service that starts the agent at login and
restarts it on crash — a launchd LaunchAgent at
~/Library/LaunchAgents/com.securecoms.agent.<slug>.plist on macOS (logs at
~/Library/Logs/securecoms-agent-<slug>.log), a systemd --user unit at
~/.config/systemd/user/securecoms-agent-<slug>.service on Linux
(journalctl --user -u securecoms-agent-<slug> -f). <slug> is the first
eight characters of the identity when they are lowercase hex (the usual case:
identities are UUIDs), otherwise the whole identity slugified to
[a-z0-9-]. Any other platform is refused.
Only the non-secret settings you pass are baked into the service definition,
under their SECURECOMS_* names; anything you omit falls back to the
runtime's own default. The installer never accepts or writes an API key
value — only --llm-key-file <path>, so no secret reaches the plist, the
unit file, or a process listing. If --llm-key-file is given, install checks the file is
readable and refuses otherwise — a key file the agent cannot read would make
it exit 2 on every start, and the service manager would restart-loop that
failure.
The Node interpreter baked into the service must survive a reboot and an
upgrade, so install does not simply use the Node you ran it with: a path whose
location encodes a version (a Homebrew Cellar keg, or nvm/asdf/fnm/volta/n) is
resolved to a stable equivalent — /usr/local/bin/node,
/opt/homebrew/bin/node, /opt/homebrew/opt/node/bin/node, /usr/bin/node,
or ~/.local/bin/node, preferring one that is the same real file as the Node
you ran, and requiring Node >= 22 on the running architecture. If no stable
Node exists, install refuses and tells you to install one or pass
--exec-path <path>, which overrides the choice outright.
Connection and identity
The runtime never reads a token from the environment. It reads the
connection file at ~/.securecoms/connections/<identity_id>.json, which
names a token file on disk; the token itself is read from that file, not
passed on the command line or in an env var. Before any socket opens, the
runtime checks a two-anchor identity gate: the identity baked into this
process (from --identity/SECURECOMS_IDENTITY) must match both the
identity recorded in the connection file and the identity the local daemon
reports on its health endpoint. Any mismatch refuses the connection outright.
Configuration
All configuration is via SECURECOMS_* environment variables (see
src/config.ts for the source of truth):
| Variable | Default | Purpose |
| --- | --- | --- |
| SECURECOMS_IDENTITY | — (or --identity <id>) | Which local daemon connection to serve |
| SECURECOMS_LLM_ENDPOINT | http://127.0.0.1:11434 | Base URL of an OpenAI-compatible LLM server, with or without a trailing /v1 |
| SECURECOMS_LLM_MODEL | llama3 | Model name to request |
| SECURECOMS_LLM_API_KEY | unset | API key for the LLM endpoint, if it requires one |
| SECURECOMS_LLM_API_KEY_FILE | unset (or --llm-key-file <path>) | File holding the API key (trimmed on read). Preferred: it wins over SECURECOMS_LLM_API_KEY, and it is the only key form the service installer accepts. An unreadable or empty file, or --llm-key-file given anywhere (argv or env), makes the runtime an EXPLICIT source; a resolution failure fails that turn only — the agent boots and logs llm: source unavailable — <reason>, it does not exit |
| SECURECOMS_LLM_SOURCE | unset | Any value fails the LLM source closed with no read of the managed files. Reserved for a future bridge-provided source; not a customer-facing setting today |
| SECURECOMS_BRAVE_KEY_FILE | unset (or --brave-key-file <path>) | File holding a Brave Search API key. A path, never a value — required for web_search |
| SECURECOMS_TAVILY_KEY_FILE | unset (or --tavily-key-file <path>) | File holding a Tavily API key. Used only when the Brave key file is absent |
| SECURECOMS_SKILLS_DIR | this package's own skills/ | Directory of skill manifests to load |
| SECURECOMS_DATA_DIR | ~/.securecoms/agent-runtime | Root directory for per-channel state |
| SECURECOMS_SKILLS_CONFIG | <dataDir>/skills.json (or --skills-config <path>) | Path to skills.json — named endpoints, databases and flags |
| SECURECOMS_LOG_LEVEL | info | One of debug, info, warn, error |
| SECURECOMS_TOOL_ALLOW | echo, web_search, fetch_url, http_request, sql_query, current_time | Comma-separated tool allow-list |
| SECURECOMS_TOOL_DENY | unset | Comma-separated tool deny-list (wins over allow) |
| SECURECOMS_TOOL_DEFAULT | deny | Fallback policy for a tool matched by neither list |
The LLM source: explicit vs managed
An explicit source (any of SECURECOMS_LLM_ENDPOINT, SECURECOMS_LLM_API_KEY,
SECURECOMS_LLM_API_KEY_FILE, or --llm-key-file on argv) always wins and is
read once per turn straight from the env/cfg above — the managed files are
never touched. With none of those set, the runtime instead looks for
~/.securecoms/runtime-config/<identityId>/llm-source.json ({provider,
model}) plus, when that provider needs one, llm.key and llm.key.host
beside it — written by the desktop app (or, later, the bridge), never by this
runtime. The endpoint is always derived from a compiled provider table, never
read from the managed file, and llm.key is refused unless its
llm.key.host sidecar names the same host the provider resolves to. Any
resolution failure in either tier fails just that turn; it never crashes the
process.
Memory
Memory is local only, never sent anywhere but the configured LLM endpoint,
and scoped per channel under <dataDir>/channels/<channel>/ — one channel's
history and captured memories never leak into another channel's turn.
Adding a skill
A skill is either a JSON manifest or a SKILL.md file, dropped under
SECURECOMS_SKILLS_DIR in one of three layouts:
- flat
<skillsDir>/<name>.json <skillsDir>/<dir>/manifest.json<skillsDir>/<dir>/SKILL.md
All three keep working side by side. The loader reads them in that order —
flat .json, then manifest.json, then SKILL.md — sorted within each
bucket for a deterministic result; if two files declare the same skill name,
the one loaded later (later bucket, or later alphabetically) wins, with a
warning naming both paths.
A SKILL.md is YAML frontmatter (between two --- lines) followed by
free-text guidance appended to the model-facing description (capped at 1 kB).
The frontmatter accepts a deliberately small subset of YAML — anything else
throws with the file and line, rather than silently misreading it:
- scalars (
key: value, optionally quoted) - flow lists (
key: [a, b, c]) - flow maps (
key: { a: 1, b: 2 }) - nested mappings (an indented block under a bare
key:), two-space indents only, no tabs - comments (
#) and blank lines - block scalars (
|,>), anchors, aliases and other YAML constructs are not supported — use a JSON manifest for anything that needs them
Example:
---
name: web_search
description: Search the web for recent information
parameters:
type: object
properties:
query: { type: string }
count: { type: number }
required: [query]
handler: builtin:web_search
requires: [search_provider]
---
Use this when the user asks about something time-sensitive or outside your
training data. Prefer a specific query over a broad one.name, description, parameters (a nested mapping) and handler are
required. handler is one of:
builtin:<name>— one of the runtime's built-in handlers (see "Curated skills" below).script:<path>— an executable file, path relative toskillsDirand contained within it (symlinks included — a link that resolves outsideskillsDiris refused). It runs with its arguments as JSON on stdin, cwd set toskillsDir, and a minimal environment (no inherited secrets).
A manifest or SKILL.md with any other handler shape, or a missing required
field, is skipped with a warning, and a skill invocation that throws is
turned into a tool_failed tool message rather than dropping the turn.
Gating a skill on configuration
A manifest or SKILL.md may carry requires: [...], and the skill is offered
to the model only when every entry is configured: endpoint:<name> and
database:<name> name entries in skills.json, search_provider means a
readable, non-empty search key file, any_endpoint / any_database mean at
least one endpoint or database is configured, and anything else is matched
against skills.json's flags.
The shipped http_request and sql_query skills declare requires:
[any_endpoint] and requires: [any_database]: the endpoint and database names
belong to the customer, so the shipped copies cannot name one, but neither is
offered to the model until skills.json gives it something to reach. With no
endpoints configured, startup prints unavailable: http_request (missing
any_endpoint). To narrow one to a particular system, copy the folder and change
the line — for example requires: [endpoint:crm] in
skills/http_request/SKILL.md.
Curated skills
Six built-in handlers ship with the runtime. All network built-ins refuse
localhost and any private, loopback or link-local resolved address, and
every tool result is capped at 20 kB (truncated, not an error). A redirect to a
different origin is followed without the caller's headers and without the
request body, so a configured header value or a search key can never be handed
to a host the operator did not name.
| Skill | Arguments | Notes |
| --- | --- | --- |
| echo | text | Returns the text unchanged. No requires. |
| current_time | timezone (an IANA name such as America/New_York; defaults to UTC) | The server clock, ISO 8601. No requires. |
| fetch_url | url | Fetches a page and returns extracted text (HTML is stripped to text). Up to 3 redirects, 10 s timeout, 2 MB response cap. |
| web_search | query, count (1–10, default 5) | requires: [search_provider] — needs a readable, non-empty SECURECOMS_BRAVE_KEY_FILE or SECURECOMS_TAVILY_KEY_FILE; Brave is used first when both are set. |
| http_request | name (a configured endpoint:<name>), path (a query string is kept, a #fragment is dropped), method (GET/POST/PUT/DELETE), body | requires: [any_endpoint]. The model names an endpoint and a path, never a host; a path that would leave the endpoint's baseUrl origin is refused. |
| sql_query | name (a configured database:<name>), sql (read-only SELECT only; a ; or a write keyword anywhere in the text — inside a string literal or a comment included — is refused) | requires: [any_database]. SQLite or Postgres (pg required separately for Postgres). Results capped at 200 rows. |
skills.json
skills.json names the endpoints and databases the curated skills are
allowed to reach, plus arbitrary boolean flags for gating. It carries paths,
never secrets — a header value or a Postgres connection string is read from
the file it names, at call time, and never logged. Relative paths (a
sqlite, postgresUrlFile or headersFile value) resolve against the data
dir, not the current working directory.
{
"endpoints": {
"crm": { "baseUrl": "https://crm.example.com/api", "headersFile": "crm-headers.json" }
},
"databases": {
"reports": { "sqlite": "reports.db" },
"warehouse": { "postgresUrlFile": "warehouse-url.txt" }
},
"flags": ["beta_tools"]
}A headersFile is a flat JSON object of header name to string value, read
fresh on every http_request call — never cached, never logged. A Postgres
database needs the optional pg package installed separately
(npm i -g pg); SQLite needs nothing extra (node:sqlite).
skills.json may also carry a tools block — {"tools": {"allow": [...],
"deny": [...]}} — the owner's on/off choices for the curated skills. A skill
that is switched off is not offered to the model at all. The environment wins:
see "Managed configuration" below.
By default skills.json lives at <dataDir>/skills.json; point elsewhere
with SECURECOMS_SKILLS_CONFIG or --skills-config <path>. A managed copy
(see below) wins over both. A missing file means no endpoints, no databases
and no flags — not an error.
Persona and memory
Three optional files make up an agent's persona, looked up along the config search path (see "Managed configuration" below) and read fresh on every turn — no restart needed after an edit:
| File | Heading |
| --- | --- |
| IDENTITY.md | ## Identity |
| SOUL.md | ## Soul |
| AGENTS.md | ## Operating rules |
Whichever of the three exist are joined, in that fixed order, under their fixed headings; each is capped at 8 kB (truncated, with a warning, if larger). If none exist, a built-in default prompt is used instead. Either way, a fixed, non-removable safety preamble is appended last, so it is the part of the prompt read most recently.
securecoms-agent persona init [--identity <id>] [--data-dir <dir>] copies
the shipped default persona files into the data dir — it never overwrites a
file that already exists, so a customized IDENTITY.md is safe to keep
across a re-init. securecoms-agent persona show prints the assembled system
prompt exactly as a turn would see it, useful for checking an edit landed.
Legacy MEMORY.md files are not active configuration. Earlier runtime versions
injected one agent-wide file into every channel, which crossed channel
audiences. On startup the runtime moves each legacy file to a mode-0600
MEMORY.quarantined*.md file in the same directory without overwriting an
existing quarantine. The quarantined content is never injected or writable by
a tool. The runtime inventories that quarantine plus inactive per-channel JSON
stores for the owner-review surface. Review offers a passphrase-encrypted
export and exact-source deletion; the 90-day clock begins only after the
machine successfully reports the source. Explicit fact-to-channel assignment
is a separate migration step and never guesses or fans out an audience.
Managed configuration (from the web app)
When an agent is managed from the SecureComs web app, the owner's saved configuration is written to disk by the customer's daemon at:
~/.securecoms/runtime-config/<identity-id>/The runtime reads its configuration along a search path: that managed
directory first, then the data dir. The lookup is per FILE, so an owner who
saved only IDENTITY.md in the app keeps the machine's own SOUL.md; a file
with no managed copy still comes from the data dir, and an agent with no
managed directory at all behaves exactly as it did before. skills.json is
looked up the same way, and re-read on every turn — a new endpoint or a skill
switched off in the app takes effect on the next message, with no restart.
One deliberate exception:
- The environment still wins for the tool policy.
SECURECOMS_TOOL_ALLOWoverrides theallowlist in thetoolsblock ofskills.json, so an install that pins its allow list in its service unit is unaffected by a save in the app.SECURECOMS_TOOL_DENYis UNIONED with the file'sdenylist rather than replacing it: deny is a safety mechanism, so one entry in the service unit must not silently un-deny every tool the owner switched off in the app. Everything the environment denies stays denied either way.
Runtime-owned writes go to the data directory, not the managed directory.
Legacy MEMORY.md is handled only by the quarantine behavior described above.
The runtime also writes mode-0600 local-config.json into the managed directory
when one exists: what this agent's OWN persona files say, booleans for readable
search/managed-LLM keys, and a bounded inventory of inert legacy-memory files.
It is written at startup and rewritten whenever those answers or the inventory
change. The daemon revalidates current regular-file bytes, removes local paths,
and only then reports it upward. Nothing in the runtime reads the report back,
and it holds no key value.
Per-turn channel authorization
Installation configuration is only a ceiling. On every inbound message the bridge supplies an immutable, server-derived authorization claim containing the sender audience, membership state, and channel tool grants. The runtime intersects that claim with installed skills, configuration requirements, and the local allow/deny gate before showing tools to the model, then repeats the same channel-grant check immediately before execution. Missing or malformed claims, inactive/unknown membership, and absent grants all fail closed.
This staged release issues no channel grants yet, so no generic tool is offered
even when it is installed and enabled. Read grants can enable only the curated
read-only tools; http_request is additionally limited to GET at execution.
Side-effect grant fields are reserved in the protocol, but side-effect calls
remain denied until exact-operation confirmation, expiry, and idempotency are
implemented end to end.
The generated tool paragraph is an installation summary
The AGENTS.md the app delivers ends in a generated paragraph naming the tools
configured for the installation, and explicitly tells the model that the
per-turn offered list is authoritative. The backend derives the installation
list from saved configuration plus the search-key boolean above; it does not
see the running process or a turn's channel grants. These things can therefore
make the installation summary differ from what the runtime offers:
- a
SECURECOMS_TOOL_ALLOWorSECURECOMS_TOOL_DENYset in the service unit, which the backend cannot see; - a customer skill folder whose
requires:is narrower than the shipped one, so the runtime withholds a tool the backend counted; - a custom skill the backend knows nothing about, so it can be eligible but unnamed;
- the current channel has no matching server-issued read grant.
The paragraph is therefore an advisory configuration summary, not an authority claim. Channel authorization and the local gate are both enforced at call time, so a configured but ungranted or denied tool is still refused.
Provenance
See PROVENANCE.md for which files were ported from
agentvault-federal's reference agent-runtime package and how the
AgentVault package-dependency cord was cut.
