npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@stratusagent/control-api

v0.6.0

Published

The Stratus control API: one authenticated HTTP + WebSocket surface over a running stratusd, consumed by the web dashboard and the macOS app alike

Downloads

130

Readme

@stratusagent/control-api

One authenticated HTTP + WebSocket surface over a running stratusd. It is the only doorway any surface uses: the web dashboard consumes it, the macOS app consumes it, and a hosted deployment is largely built on it.

Optional on purpose. stratusd runs without it, and installing it is how you say you want a port open — which is also what lets it carry a real WebSocket dependency without weighting the always-on core.

npm install -g @stratusagent/control-api
stratus serve                       # binds http://127.0.0.1:4123

The web UI is a separate package (@stratusagent/dashboard). This one serves /api/v1 and nothing else, because its other two consumers — the macOS app and a headless VM — want the API and not a web page.

Authentication

Two credentials, one check. Every endpoint requires one of them.

Bearer token — generated into ~/.stratus/gateway-token (0600) the first time the API binds. Programmatic clients read the file and send it:

curl -H "Authorization: Bearer $(cat ~/.stratus/gateway-token)" \
  http://127.0.0.1:4123/api/v1/agents

The file is claimed by linking an already-written file into place, so two daemons starting together on one home agree on a token instead of the loser authenticating against a value no client can read, and a process killed mid-write leaves no half-created file. A token file that is empty is refused with a message naming it rather than repaired: repair means replacing a file another daemon may have just claimed, and there is no conditional replace to do it safely. Delete it and start again.

Session cookie — a browser can do neither half of that: page JavaScript cannot read the token file, and a WebSocket upgrade cannot carry an Authorization header. So stratus dashboard mints a one-time token and opens the browser at it:

POST /api/v1/auth/ott          → { ott, url }          (bearer only)
GET  /api/v1/auth/session?ott= → 302 /, Set-Cookie      (single use, 60s)

POST /auth/ott answers with { ott, url, path }. path is the exchange link with no origin on it, and a client holding the address it just reached should prefer joining that to its own base: url is built from the request's Host (and x-forwarded-proto, read for nothing else), which is right for a LAN address or a tunnel but is still the daemon's best guess rather than the caller's own knowledge.

The cookie is HttpOnly, SameSite=Strict, and rides along on the WebSocket upgrade, so /api/v1/events authenticates the same way. It carries no Secure flag: the gateway serves plain HTTP on loopback, and a Secure cookie would simply never be sent back — the flag would read as hardening while breaking every request. TLS is a tunnel's job.

Sessions live in memory, so restarting the daemon signs the browser out. Run stratus dashboard again.

Origin binding. SameSite matching ignores ports, so a page served from another port on the same host counts as the same site and its requests carry the cookie automatically — and WebSockets get no CORS protection at all. So every WS upgrade and every state-changing request made with a cookie is checked against this gateway's exact origin, port included. Bearer requests are exempt: nothing attaches that header on a page's behalf, so there is no ambient authority to forge.

"Exact origin" means the address the browser actually reached the daemon on, which is routinely not the one it bound to — a wildcard bind is reached over a LAN or Tailscale address, and a tunnel terminates TLS in front of a loopback one. So an origin equal to the request's own Host is accepted, under http or https. That is a same-origin check rather than a concession: a browser sets Host from the address it connected to, never from the page making the request, and cannot be made to send another one — Host is a forbidden header name for fetch, XMLHttpRequest, forms, and WebSockets alike. A page on another port, or another host, still fails.

Localhost binding is the posture. Remote access is the operator's tunnel decision — Tailscale is the pattern we recommend for reaching a machine at home. There are no user accounts; that belongs to a hosted deployment.

Endpoints

Everything is under /api/v1. A path prefix rather than a header, because the macOS app pins against it and a version you can see in a curl, a proxy log, and an address bar is one that gets noticed when it changes.

| Method | Path | What | | --- | --- | --- | | GET | /health | Uptime, roster, session counts, pending approvals, resolved runtimes | | GET | /agents | The roster as data — soul metadata, avatar palette, resolved provider/model, memory counts, activity | | POST | /agents | Create an agent: writes a soul file and reloads the roster | | GET | /agents/:id | One agent in full: complete instructions, the raw soul markdown, its pins | | PUT | /agents/:id | Edit a soul, by field or as raw markdown | | POST | /roster/reload | Re-read the agents directory and the configured default soul | | GET | /sessions?agent=&limit= | Durable sessions, newest first. limit bounds the result — the table grows for the life of an install | | GET | /sessions/:id | One session, provider replay state stripped | | POST | /sessions/:id/messages | Dispatch a message; returns 202 { sessionId, turnId } | | GET | /approvals | Calls parked on a human right now | | POST | /approvals | Resolve one: { requestId, answer, actor? } | | GET | /catalog/models | Models the stored sign-ins can actually reach, listed live | | GET | /catalog/tools | Every registered tool with the risk a call will face, and the plugins that contributed them | | GET | /credentials | Which sign-ins exist — presence and endpoint, never a value | | POST | /credentials/verify | Live-check a key before storing it: { provider, key, type?, baseUrl? } | | PUT | /credentials/:provider | Store an api_key, or an oauth_token for Anthropic | | PUT | /credentials/channels/:channel | Store a channel's tokens (today: slack) | | GET/PUT | /config | Settings, whitelisted to keys this API owns |

POST /credentials/verify reports ok, rejected, or unreachable, and only an explicit 401/403 is rejected — a compatible endpoint with no models route says nothing about the key. Pass type with it: a oauth_token cannot call the models endpoint at all, so it answers unreachable rather than sending a Claude subscription token as an x-api-key and condemning a credential that works perfectly well once saved.

PUT /config does not write the plugins block. GET returns it, and a PUT carrying it back is accepted (the round trip has to work) but the value is ignored and the file's existing block is preserved rather than deleted by the replace. Enabling a plugin runs somebody else's code inside the daemon — that is the boundary the whole trust model rests on, and it stays a deliberate edit to a file rather than a settings save.

PUT /config replaces the file rather than merging into it — GET hands you the whole document and PUT takes the whole document back, so a partial body silently drops the keys it omits. Every accepted key is type-checked first: the config loader ignores values of the wrong shape, so writing one would leave the file, the response, and the running daemon disagreeing about what was just saved.

It edits the config the operator chose — the file named by --config or STRATUS_CONFIG, or the global ~/.stratus/config.json. Never an auto-discovered project-local stratus.config.json: that file ships in a repository, and writing settings into somebody's checkout because the daemon started there would surprise everyone (its api and approvals blocks are ignored for the same reason). | WS | /events | The live event stream |

GET /catalog/tools answers two questions, not one

{
  "tools": [
    { "name": "demo.echo", "risk": "safe" },
    { "name": "fs.read", "risk": "safe", "package": "@stratusagent/tool-fs", "trusted": true },
    { "name": "notes.read", "risk": "gated", "package": "stratus-plugin-notes", "trusted": false }
  ],
  "plugins": [
    { "package": "@stratusagent/tool-fs", "name": "@stratusagent/tool-fs", "trusted": true,
      "tools": [{ "name": "fs.read", "risk": "safe", "package": "@stratusagent/tool-fs", "trusted": true }] },
    { "package": "stratus-plugin-typo", "error": "Cannot find package 'stratus-plugin-typo'" }
  ]
}

Both halves, because either alone misleads. The tools say what an agent can be granted and at what risk; the plugins say what this daemon was asked to load — including one that failed, which is invisible in a list of tools and is usually why somebody opened the screen.

A tool with no package is the kernel's, which is the honest answer for it rather than an omission. risk is read from the live registry, so it is the risk a call will actually face: a third-party package cannot declare its own tool safe, and this reports the floored value rather than the manifest's claim.

Nothing here says which agent may call what — that is the soul's allowlist, and it is per identity. GET /agents/:id carries it.

runsOn is absent when the daemon cannot say

Each agent in GET /agents and GET /health carries runsOn — the provider and model a turn as that agent would actually resolve to, normalized through the same soul-pin rules dispatch applies.

It is omitted rather than defaulted when the agent's soul file cannot be read. The gateway keeps dispatching from a cached soul when its file is deleted or momentarily unparseable, and that soul may pin a provider, so the daemon is still billing somewhere it can no longer name. A default of demo there would be a false statement about money, made exactly when someone is looking to find out what is running.

The listing summarises; the single read does not

GET /agents carries persona: the agent's first instruction line, trimmed to fit a table row. GET /agents/:id carries agent.instructions in full, plus soul — the file's own bytes, which is what PUT /agents/:id accepts back as its soul field.

The distinction matters to anything that edits. An editor seeded from the roster's persona and saved would write that snippet back as the whole persona, permanently truncating the agent to a fragment of its first sentence the first time someone changed its name. Read the agent before editing it.

Activity, for a roster that shows who is busy

Each entry in GET /agents carries activeSessions (turns running or parked on a human right now) and, once the agent has done anything, lastActiveAt.

Both, because neither is sufficient. A timestamp alone reads a turn parked on an approval as idle — the save that recorded the park is the last thing that touched the row, and a turn waiting twenty minutes on a person is exactly when you want the agent lit. A count alone loses an agent that finished a moment ago.

What counts as "recently active" is the client's decision, so this reports a timestamp and a count and never a verdict. A daemon that baked a window in would need upgrading to change it.

Two invariants worth stating

  • Channel tokens have their own door. Slack app and bot tokens are gateway infrastructure secrets, not agent capabilities. Only PUT /credentials/channels/:channel writes them; the provider-credential and config endpoints cannot reach that namespace.
  • No endpoint returns a secret. Credential reads report presence, type, and bound endpoint. Session reads strip the Anthropic raw-turn cache, which exists for replay and carries raw model turns.

Health does not probe

GET /health reports what resolution already knows: uptime, the roster, each agent's resolved provider and model, session counts by status, pending approvals, and the distinct runtimes the daemon would serve with how each got its credentials. It makes no network call — a monitoring view polls this, and a live call per poll would spend the operator's rate limit to say something resolution can answer for free.

Both the roster and the runtimes come from the roster the gateway is serving, with each soul re-read the way refreshAgent re-reads it before every dispatch — so a pin edited on disk is reported by the same poll that the next turn will bill it on. It is the roster, not the agents directory.

The two ways a re-read can fail are not the same answer. An unreadable file keeps its cached pins, because the gateway goes on dispatching from the soul it loaded. A file that now declares a different agent id contributes no runtime at all: refreshAgent refuses every dispatch for the old id until the roster reloads, so there is nothing being served under it to report. The two diverge in ordinary use: a soul dropped on disk is not dispatchable until a reload, and a soul deleted or left momentarily unparseable is still dispatched from the copy the gateway loaded. Reading the directory would name a provider and model for turns the daemon refuses to run, and omit the pins it is really billing — in the one endpoint whose job is to say what the daemon is doing right now. POST /roster/reload is what closes the gap.

The event stream

WS /api/v1/events, filterable at connect (?session=, ?agent=) or with a frame:

{ "type": "subscribe", "sessionId": "…", "agentId": "…" }

Frames are envelopes:

{ "sessionId": "…", "turnId": "…", "event": { "type": "provider.delta", … } }

The event is the existing StratusEvent union, unchanged — no new vocabulary. The turn id lives on the envelope because StratusEvent carries none and should not grow one: a session processes several messages in sequence, and without this a client that queued one has no way to tell its own deltas from the next caller's. The id is assigned at dispatch and returned by POST /sessions/:id/messages.

Deltas are dropped for a client whose socket has backed up past 1 MB, and only deltas: losing a token from a reply is a cosmetic gap, while losing a completion, a failure, or an approval leaves a UI stuck on a turn that already ended. A { "type": "dropped", "deltas": n } frame says when it happened.

Configuration

{
  "api": {
    "enabled": true,        // false, or `stratus serve --no-api`, to turn it off
    "host": "127.0.0.1",
    "port": 4123
  }
}

While it is serving, ~/.stratus/gateway.json (0600) says where:

{ "url": "http://127.0.0.1:4123", "host": "127.0.0.1", "port": 4123, "pid": 4242, "version": "0.6.0" }

Clients read it instead of guessing at a default the operator may have changed. It is removed on a clean stop, and only by the process that wrote it.