AGENT SKILL

stratus

Use whenever someone asks how Stratus itself works or how to set something up in it, such as where a config, credential, soul, log, or workspace lives, how to give you an API key or a new tool, why you can or cannot do something, what you remember and how to make you forget it, why a reply stopped partway or came from another model, which version is running, which stratus command does what, or how Slack, approvals, plugins, or the daemon behave. Read it before you search files or guess.

使用场景邮件与沟通
License 许可
Apache-2.0
所属仓库
stratuslabs/agent
最近更新
2026-11-14
Stars
★ 5
Forks
2

01 前言 Front Matter

name
stratus
description
Use whenever someone asks how Stratus itself works or how to set something up in it, such as where a config, credential, soul, log, or workspace lives, how to give you an API key or a new tool, why you can or cannot do something, what you remember and how to make you forget it, why a reply stopped partway or came from another model, which version is running, which stratus command does what, or how Slack, approvals, plugins, or the daemon behave. Read it before you search files or guess.

03 SKILL.md 正文

How Stratus works

You run inside Stratus: a daemon (stratusd, started by stratus serve) that hosts a roster of agents, connects them to channels like Slack, and runs their tools. This is the map of that system, so you can answer questions about your own setup accurately and ask your operator for exactly the right thing.

Check your instructions first. They name your id, your soul file, your workspace, the model answering as you, the credentials you hold (by name), the tools you have, and the room you are talking in. Those facts are about you, now, and they beat anything general below.

Four rules for using what follows:

  • Say what you can check, and check it. When a question is about this install (is a plugin enabled, is a key stored, why a setting isn't taking), the answer is in a command your operator can run. Give the command rather than a guess, and never promise an ability you do not have yet: say what you will be able to do once the step is done.
  • Never ask anyone to paste a secret into a conversation, and never offer to store one you were given in chat. If someone pastes one anyway, do not repeat it and never memory.remember it. Tell them it is now in the conversation, your transcript, and the model provider's request, so it should be rotated, and give them the safe way to store the new one.
  • Mind the room. Your instructions say whether this is a direct message or a channel, and everyone in a channel reads what you post. A credential link, someone's private settings, or anything else meant for one person never goes into a shared channel. You cannot open a direct message yourself, so ask the person to message you directly and give it to them there.
  • Your tools run on the machine the daemon runs on, not necessarily the computer of the person you are talking to. You cannot tell which from inside, so ask before promising access to "my laptop" or "my files".

Where things live

Everything is under ~/.stratus/ on the daemon's machine:

Path What it is
config.json The global config, and the trusted one (see "Config"). 0600, because a plugin's config in it can hold secrets such as tool-shell's env, and every save keeps it that way; stratus doctor flags one other users can read
credentials.json Provider sign-ins (shared, and any agent's own under agentSignIns.<id>), channel secrets (Slack's tokens and any channel plugin's, under channels.<kind>.<agentId>), and named credentials. 0600, and kept that way by Stratus, which is why it is edited with stratus credential set and stratus channel set rather than by hand
credential-audit.log One line per named credential added or replaced from a Slack form or a link: when, which name, whose store, added or replaced, the approver (for a form), and form or link. Names only, never a value. 0600
agents/<file>.md A soul: one agent's identity and allowlists. The file name is not the id; the id is the soul's id:, or derived from its name:. Use the id your instructions give
agents/<id>/ That agent's state: sessions.db, memory.jsonl, whitelist.json (its standing approvals), and workspace/
agents/<id>/workspace/ The agent's own working directory, where shell.run starts by default
fleet.db Schedules, and the index of every session
skills/<id>/SKILL.md Skills the operator installed
plugins/<package>/ What a plugin keeps across restarts, such as a channel's read position. 0700
logs/stratusd.jsonl The daemon's structured log, read with stratus logs
gateway.json, gateway-token Where the control API is listening, and its bearer token. 0600
stratusd.lock The running daemon's claim on this home: one daemon per home

Souls and workspaces are normally links into a visible agents folder in the home directory: agents/<id>.md points to ~/Agents/<id>/soul.md and agents/<id>/workspace to ~/Agents/<id>/workspace/. That is where a person edits a soul or finds an agent's files. The folder is ~/Stratus instead when ~/Agents already belonged to something else. There is none (everything stays in ~/.stratus/agents/) when both names are taken, when ~/.stratus is on a different disk from the home directory (the Docker image), or with STRATUS_AGENTS_DIR=off; STRATUS_AGENTS_DIR=<path> chooses one outright, and stratus doctor names the one in use. A soul or workspace still kept as a real file under ~/.stratus/agents/ is moved there by the next stratus serve or stratus update. fs.* refuses every soul, under either name.

Deleting a soul file takes the agent off the roster but keeps its agents/<id>/ state, its standing approvals included (a new agent given the same id inherits them), and its schedules in fleet.db. Deleting agents/<id>/ erases its history, memories, and grants; the files in ~/Agents/<id>/workspace/ stay until that folder is deleted too. Deleting or renaming ~/Agents/<id>/ takes the agent off the roster (its state is untouched) until the folder is put back.

Your soul

A soul is a markdown file: frontmatter for identity and allowlists, then the persona as prose.

  • name; id (derived from the name when absent, so renaming a soul with no id: changes it too; a changed id starts the agent fresh, and its old history, memories, and grants stay under the old id); provider and model; language (for example en-GB); effort (low, medium, high, xhigh or max: how hard that agent's Claude model works per turn, for its model and its fallback; a model without the level gets the highest one below it, unset sends nothing, and stratus serve logs what each model is actually sent); listens (see "Slack").
  • tools: is the allowlist of what you may call: exact names (fs.read), toolset globs (fs.*), or "*" for every registered tool. Omitted means only the tools registered as safe (reads, search and memory), and tools: [] means none. New souls from setup get tools: ["*"]. On a home set up before 0.14.3, an omitted list still means every registered tool. When you lack a tool, ask for that one name to be added rather than for "*", which grants the shell too.
  • skills: enables skills. Omitted means none, except built-in skills like this one, which every agent has.
  • credentials: names the credentials you may use. Omitted means none.
  • delegates: names the agents agent.delegate may hand work to (* for anyone). Omitted means nobody.

Under stratus serve the daemon reads your soul again before every turn, so an edit reaches your next reply with no restart. Its contents are already in your instructions; there is nothing to open. A new soul file, or a deleted one, needs the roster reloaded: the dashboard and the control API do it (POST /api/v1/roster/reload), and so does stratus restart.

Config

The config decides which provider and model run, which plugins load, and how approvals work. The file is the one --config or STRATUS_CONFIG names, else a stratus.config.json in the directory the daemon started in (for the service, wherever stratus service install ran), else ~/.stratus/config.json.

Which provider and model a turn uses: for stratus run and stratus chat, command-line flags beat STRATUS_* environment variables, which beat the soul's provider/model, which beat the file. Under stratus serve a soul's provider/model beats all of those, because each agent in a roster runs on its own. A changed provider or model in the config reaches the next turn without a restart.

A project-local stratus.config.json ships inside repositories, so it is untrusted. It cannot set plugins, approvals, principals, slack, api, maxTurns, agentMaxTurns, autoContinue, concurrentScheduledTurns, tasks, apiKeyEnv, soul, systemPrompt, executor, or memoryStore. Those are read only from ~/.stratus/config.json or a file named by --config or STRATUS_CONFIG. A project-local file that sets one of them has it ignored, with a warning. For principals, slack, approvals, maxTurns, executor, and memoryStore the daemon uses ~/.stratus/config.json's instead; for plugins and api it uses none. A project-local file that says nothing about one of those eight leaves the global one in force.

When a setting "isn't taking", these say why:

  • stratus doctor shows what stratus run and stratus chat would use, and which file or variable decided each value. When a daemon is running it also asks it what it warned about at start, and says when the config has changed since then (a restart applies it).
  • stratus agents shows what each agent on the roster runs on.
  • stratus plugins shows what the config's approvals mode does with each tool. stratus serve --approvals overrides that mode. A daemon in remote logs an approvals: remote line at startup, so a stratus logs with no such line since the last start means headless.

Credentials and API keys

This is the question you will be asked most. A secret reaches Stratus in one of these ways, and which one depends on what uses it:

  • Provider sign-ins (the model's own key or subscription): stratus setup → Providers, or the dashboard. There is no stratus login.

  • One agent on its own Claude subscription: stratus signin set anthropic --agent <id> at the machine, with a token from claude setup-token typed at the hidden prompt or piped on stdin. It replaces the shared Anthropic sign-in for that agent only, from its next turn, no restart. stratus signin remove anthropic --agent <id> goes back to the shared one, and stratus signins lists who has one. It is not a named credential: you cannot request, read, or resolve it, and credential.request is the wrong tool for it. If its token is refused, the turn fails with an error naming that agent's sign-in rather than moving onto the shared account.

  • Channel tokens (a Slack app's tokens): stratus setup → Channels, or the dashboard. No agent can read them, yours included.

  • Named credentials, such as search.apiKey or github.token, are for tools whose plugin declares that name, and for shell commands that read one from an environment variable (next). A web search backend is the common one: its key is always search.apiKey, whatever the vendor. These are the only secrets you can ask for yourself.

  • A shell command's token reaches shell.run as an environment variable, so a CLI or script that reads its token from one just works. It is a named credential like any other (your soul must list it), bound to the variable one of these ways:

    • Ask for it with env: credential.request with the name and env: "TWILIO_AUTH_TOKEN". The form tells the approver your shell will be able to read it as that variable, and answering it is their approval for that. Once they add it, your commands have $TWILIO_AUTH_TOKEN from your next reply, with no config edit and no restart. env must be uppercase and end in _TOKEN, _KEY, _SECRET, _PASSWORD, _SID or _ID; any other name is refused before anyone is asked. For a key you already hold, it is refused with the one command your operator runs instead.
    • Your operator binds it at the machine: stratus credential env <name> <VAR> --agent <id>, or stratus credential set <name> --agent <id> --env <VAR> while storing it. Any variable except ones that change how commands run (PATH, HOME, BASH_ENV, GIT_DIR and the like), and it applies from the next command with no restart. --remove undoes it, and removing the key removes its bindings.
    • The trusted config, with @stratusagent/tool-shell's credentialsEnv: { "GH_TOKEN": "github.token" }, fleet-wide or under agents.<id> for one agent. It is the operator's pin: a variable it maps is never taken from a binding. A config edit needs a daemon restart;
    • or as a value written into tool-shell's env, under agents.<id> to keep it to one agent. An agent's env is added to the shared one rather than replacing it, its own names win, and a name set to null there is withheld from that agent. A config edit, so a restart.

    Either way your commands can read the value (echo $GH_TOKEN prints it), so only narrowly scoped tokens belong there.

  • An MCP server's token is not a named credential: MCP servers never read the credential store. Your operator puts it in servers.<name>.headers (HTTP) or servers.<name>.env (stdio) in the trusted config at the machine, which every agent granted mcp.<name>.* uses, and the daemon needs a restart. Its replies are labelled external, and so is everything you remember after reading them, unless the operator sets servers.<name>.outputTrust to agent (or unknown) for a server they run; never user.

    passEnv beside tool-shell's or a server's env forwards variables, values included, from the daemon's own environment, and only ones that are not secret belong on it.

For a named credential to reach you, three things must all be true. It is stored, your soul's credentials: lists its name, and a tool you have declares it, or it is bound to a variable for your shell (tool-shell reads bindings and the names its credentialsEnv maps). Storing grants nothing by itself. Tools use the value on your behalf, and there is no file for you to look in. The one place a value shows up is a shell.run command's environment, in the variable it is bound or mapped to. Stored entries are read before an environment variable of the same name, and your own entry before a shared one.

When you need a named credential you do not hold, first make sure a tool you have uses that name, or that a command you run reads it from an environment variable (then ask with env). A key nothing reads does nothing once stored; if what you need is a tool, say which plugin or tool is missing instead. Then:

In Slack, the form comes first. When someone in a Slack conversation asks how to give you a key, or you need one there, put up the form with credential.request instead of explaining terminal steps: the value never touches the conversation, and nobody has to be at the machine. Give the stratus credential set command only when they ask for it, when the tool says no form can go up here, or when they say they are at the machine. For example, asked in a Slack thread "how do I give you the GitHub token?", call credential.request with name: "github.token" and a reason, then reply: "I put a secure form in this thread. An approver enters the token there, it goes straight to the store, and I can use it from my next reply." Not a paragraph of read -rs KEY steps.

  1. Ask with credential.request. Under the daemon every agent has it, whatever its tools: says (the built-in stratus agent, which has no soul, cannot use it). Give the name the tool expects, a one-sentence reason, and a scope: agent (yours alone, the default) or shared (one value stored for the whole fleet, but still granted only to you; another agent needs the name in its own soul).

    • A form, in Slack, goes up in this conversation when one of approvals.slackApprovers can see it: a public channel, a private one with an approver in it, or a direct message with an approver.
    • A link comes back otherwise, or when you pass via: "link", but only if your soul's tools: lists credential.request (by name, a glob, or "*"), or you have no tools: key on a home set up before 0.14.3; otherwise it is a form or nothing. It needs the control API, and without api.publicUrl it opens only on the daemon's machine. Anyone who has the link can set the key, once, within 30 minutes, with no approver involved, and opening it spends nothing. So give it only to your operator. In a shared channel, or a direct message with anyone else, do not post it: ask your operator to message you directly, and ask again there. Pass via: "form" to get a form or nothing.
    • Either way the key goes straight to the store and the name into your soul, and you can use it from your next reply. A restart cancels a request still waiting.
    • A key that spans lines, such as a GitHub App's PEM private key, can go through the form or a link: both keep line breaks. The Slack form's field holds up to 3,000 characters, so for a longer key ask your operator to run stratus credential set <name> --agent <your id> < key.pem on the machine. A PEM key that arrives with its line breaks lost is refused, so nothing broken is stored.
  2. Otherwise, ask your operator to run, on the machine:

    read -rs KEY && printf %s "$KEY" | stratus credential set <name> --agent <your id>
    

    (leave out --agent for a shared one; for a key file, stratus credential set <name> --agent <your id> < key.pem), and to add <name> to credentials: in your soul.

Away from the machine, the control API and a link only add: they store a name nobody has stored, and refuse one already stored or supplied by the daemon's environment. The Slack form adds too, and it is the one way to replace a key from chat. When one you hold is expiring or was revoked, call credential.request with replace: true. That is always a form an approver answers in this conversation, never a link. It is refused before anyone is asked when you don't hold the key, when it comes from the daemon's environment, or when your scope doesn't match where it is stored: a shared key needs scope: "shared", because replacing it changes the key for every agent granted it. The new value is used from the next tool call or command. At the machine, stratus credential set replaces as well, and stratus credential remove is the only way to remove one. Each add or replace from a form or a link appends a line to ~/.stratus/credential-audit.log, names only, with the variable when one was bound, and so does each stratus credential env. stratus credentials lists the names stored, shared and per agent, and what each agent's shell reads, never the values.

A stored named credential or provider key takes effect on the next turn, with no restart. One that comes from an environment variable is fixed when the daemon starts, and the background service does not read a shell profile.

When a stored key "isn't found", check in this order:

  1. Is the name in your credentials:?
  2. Does a tool you have use it? The plugin's README names what it reads.
  3. Is it stored under that exact name, shared or for you (stratus credentials)?

Tools, plugins, and approvals

  • Built in everywhere: memory.* (remember, recall, forget, pin) and skill.read, which every agent has because this skill counts. Only under the daemon: schedule.*, message.send, message.read, channel.create, channel.join, channel.invite, channel.archive, agent.delegate, and credential.request, so stratus run and stratus chat cannot call them.
  • Plugins add the rest, each installed with npm install -g <package> and enabled under plugins in the trusted config:
    • @stratusagent/tool-fs gives fs.*, inside configured roots only, so with no roots there is no filesystem. Whatever the roots, fs.* never reaches ~/.stratus outside the agents' workspaces (credentials, config, logs, sessions, memories, souls, skills, grants), so a refusal there is by design, not a roots problem. Roots go in "@stratusagent/tool-fs": { "enabled": true, "roots": ["~/notes"] }, or under "agents": { "<id>": { "roots": [...] } } for one agent.
    • @stratusagent/tool-shell gives shell.run.
    • @stratusagent/tool-web gives web.fetch. web.search is not first-party: it comes from a search backend plugin someone else publishes, and needs search.apiKey.
    • @stratusagent/tool-browser gives browser.*.
    • @stratusagent/plugin-mcp gives an MCP server's tools as mcp.<server>.<tool>.
  • stratus setup → Plugins installs, enables, and asks for settings like roots in one step.
  • Enabled is not granted: your soul's tools: must also cover a plugin's tools. stratus plugins walks the whole chain for each tool (installed, enabled, granted to which agents, and what approvals do with a call), so it answers "why can't you use X" in one command.
  • Risk. Every tool is safe, gated, or dangerous. The daemon runs safe tools unattended, among them memory.*, skill.read, credential.request, agent.delegate, schedule.list, schedule.cancel, and fs.read, fs.list, and fs.search. agent.delegate is judged as gated once the conversation has read external content (a web page, a search result, an MCP reply). Gated tools include:
    • web.fetch, fs.write, fs.move, shell.run, browser.*, and mcp.*;
    • schedule.every, schedule.at, schedule.on (an event trigger: your prompt runs when a signed delivery from a source the operator configured passes your filter, labeled external; it is refused for a source the daemon doesn't have), message.send, message.read (which reads a channel your Slack app is in, or the DM you're talking in, never any other DM, and labels what it returns external; a top-level read also returns the channel's bookmarks, which need the bookmarks:read scope; images on the messages read are shown to you after the result, at most 5 and 10 MB per read, with Slack's preview for one over 5 MB, and each message's shownImages and imageNotes say which you were shown and why any other wasn't, so read fewer messages to see the rest and never describe an image you weren't shown; a DM read is refused before any approval prompt, since approving can't make it run), and channel.create (which makes a channel with your own app and invites people and agents; on Slack it needs the channels:manage or groups:write scope, which apps don't have by default, and a failed invite or topic is reported while the channel stays, so don't retry the create), channel.join (which joins an existing public channel with your own app, by its C… id, never a name; on Slack it needs the channels:join scope, and a private channel can't be joined at all: a member has to invite your app) and channel.invite (which invites people and agents to a channel your app is already in; on Slack it needs channels:write.invites or groups:write.invites, or the channel.create scopes; it fails only when nobody was invited, otherwise notInvited says who wasn't and why), and channel.archive (which archives a channel your own app created, by its id; it refuses a channel anyone else made, a DM, a shared channel and the conversation you were asked from, that last one before any approval prompt; you can't undo it, but messages are kept and a person can unarchive it in Slack; on Slack it uses the channel.create scopes, and it refuses every channel when another agent here shares your Slack app);
    • every third-party tool, web.search included.
    • Under the default approvals.posture: "open" a gated call runs without asking; what needs approval is a dangerous call (and, under externalContent: "gate", a gated one after external content). Under "guarded" every uncovered gated call does. Under "review" a gated call runs unreviewed until your conversation reads outside content; after that, the operator's approvals.rules (tool, at most one of command/origin/destination, verdict allow/ask/block; first match wins, and they hold in every session), built-in rules (a cloud metadata address or a token in an outbound call blocks, curl … | sh asks) and a reviewer model on approvals.review.model decide. The reviewer sees only what your people asked for and the call, never pages or tool output. A reviewer block's error tells you to ask the person to confirm, and their confirmation is what the next review sees; there's no flag to get past it. Every ask under review is one-shot (no Always allow), and a review that couldn't run asks. approvals.review.shadow: true keeps you on open and only logs what review would have done. To turn it on for yourself, an operator sets posture: "review" (and usually review.shadow: true first) in a trusted config and restarts; the review section of the approvals guide has the details. What happens to a call that needs approval depends on the approvals mode. Under headless (the default) it is refused. Under remote the people in approvals.slackApprovers are asked in Slack (or through the control API's /approvals) with Allow once, Always allow, and Deny. Under remote with nobody to ask, it is refused too.
    • A refused call's error is the daemon's reason: which check refused it (a command scope, a grant, the gate, a dangerous tool, an approver's no) and what would have covered it. Act on that, by doing it another way or telling the person what approval it needs, rather than calling the same thing again. Each decision, allowed or refused, shows beside the call in the dashboard while the turn runs, and stratus logs keeps a permission.decided line per call with the tool, whether it ran, and the check.
    • Either way, what was already approved still runs unattended. For shell.run that is a command scope, a command granted by name, plus a short built-in safe list (cd, pwd, echo, true, false, whoami, and uname; no git command, not even git status, since git runs programs a repository names), plus grep, head, tail, wc, sort, and uniq as filters that are never handed a path, and cat, head, tail, and ls of files inside your workspace. Always allow on a command like grep grants it by name: any arguments, as long as the line runs in your workspace and every path it names resolves inside it. A command with subcommands (git push, gh pr) keeps a command-plus-subcommand scope, and shells, interpreters, wrappers like xargs or sudo, and system commands are never granted by name. A line joined by &&, ||, ;, or | runs unattended when every command in it would on its own, and output may be redirected into your workspace or /dev/null; a background &, a newline, $( ), a heredoc, or a redirection elsewhere still means asking. For browser.act and web.fetch it is an approved site (a web.fetch redirect to another site is reported as redirectedTo, not followed), and for other gated tools a standing grant.
    • The exception is approvals.externalContent: "gate", set for every agent or per agent under approvals.agents.<id>. Once your conversation has read external content (web.fetch, browser.*, web.search, or an mcp.* tool), no grant applies for the rest of it: every gated call is asked or, headless, refused, and Always allow is not offered. safe tools, the built-in safe list (cd, pwd, echo, whoami, uname, and the stdin filters), workspace reads, and a schedule's pre-authorized destination still run. Your next conversation starts with its grants again.
    • Always allow writes one of those to agents/<id>/whitelist.json, except for a tool that names a destination, such as message.send, where it lasts for the conversation. A dangerous call is never offered it.
    • An operator can also authorize channels in config with approvals.channels (top level or under approvals.agents.<id>): ["slack:T0123ABCD/C0123ABCD"] (workspace id, then channel id; the entry only covers that channel while your app is in that workspace), ["slack:T0123ABCD/*"] for every channel your app is in there, or ["slack:*"] for every channel your Slack app is in. A bare slack:C0123ABCD covers nothing. Then message.read and message.send there run without asking in every session, scheduled and delegated included, and it survives restarts and updates. Sends to DMs and group DMs still ask (DM reads are refused outright), Slack membership and scopes still apply, and the external-content gate still withdraws it. Revoking means removing the entry and restarting the daemon. It is read only from a trusted config (the global one, or the file --config names), never a project config.
    • stratus grants <id> lists the grants, and stratus grants revoke <id> --tool|--scope|--origin takes one back (--scope grep for a command granted by name). There is no command to add one: a headless daemon asks nobody, so it never creates a grant, and a call nobody has approved yet needs remote mode first.

Memory

  • What you have. Every turn carries the facts you pinned, an index of the topics you know about, and a short tail of what you learned most recently. Everything else is reachable with memory.recall. memory.remember writes a fact, memory.forget retires one by id, and memory.pin keeps one in your instructions every turn (pins share a 2 KiB budget and are refused past it, never evicted).
  • Where it lives. By default agents/<id>/memory.jsonl, one agent's own. A forgotten fact stops reaching you and recall, but its line stays in the file as a record. A trusted config can select another store (memoryStore, from a plugin), and then the memories live wherever that store keeps them.
  • Making you forget something. Recall it to find its id and memory.forget it, then say you did. Your operator can do the same at the machine with stratus memory list <id> and stratus memory forget <id> <entry>, for the default store only: with another memoryStore selected, stratus memory refuses, and that store's own tooling is the way. Never claim a fact is gone without having forgotten it.
  • Trust labels. Every fact carries who wrote it: user (your operator at a local terminal, or a Slack sender named under principals, said it), agent (your own work in a conversation of trusted content), unknown (no recorded origin, or written after a message from someone not a principal, or after shell.run), or external (written after you read a web page, a search result, or an MCP reply). Label facts by what they are when you repeat them: an external or unknown one may be a stranger's words.
  • A label only ever goes down within a conversation, and only your operator can raise one, with stratus memory reassert at the machine. No tool can.
  • Memory passes. A plugin your operator installs can have the daemon run short passes, as you, that read a quiet conversation's new messages and save its lasting facts (at most 5, never task notes, at the conversation's label, each carrying metadata.memoryPass). Core schedules none, and no tool starts one, so you can't run one yourself: if someone wants it, they install and configure that plugin. DMs are read only when the plugin's config says so. Your operator audits with stratus memory passes <id> and stratus memory list <id> --pass <pass>, and takes one back with stratus memory undo-pass <id> <pass>, which forgets what it wrote and brings back what it replaced. In shadow mode a pass writes nothing real. See docs/concepts/memory.md.

Slack

  • One Slack app per agent, each with its own tokens, connected over Socket Mode, so no public address is needed. Set up with stratus setup → Channels, which prints the app manifest and checks the tokens.

  • "Sending messages to this app has been turned off" in a DM is a Slack setting, not Stratus. In the app's settings go to App Home → Messages Tab, turn on Allow users to send Slash commands and messages from the messages tab, then reopen the DM.

  • What reaches you, from a sender you admit (by default only those in principals.slackUsers; see Who counts as your operator below). In a DM, every message. In a channel, a message that mentions you, which starts a thread. In a channel listed under your slack.agents.<id>.homeChannels in the trusted config, every new top-level message too, unless it names another agent (restart the daemon after changing it). In that thread, what happens to a reply that does not name you depends on your soul's listens::

    • thread, the default, hears every such reply. One that only acknowledges or reports may get no answer: your turn is told so, and answering exactly NO_REPLY posts nothing.
    • mentions answers only messages that name you, though it still hears the rest of the thread.
    • judge hears them and decides whether to answer.
  • Staying quiet. A message from a person that tags you, and every DM, always gets an answer. Another agent's tag and an untagged reply in your thread (on listens: thread) come with a note saying they may need nothing back; when one only acknowledges, thanks you or reports, answer exactly NO_REPLY and nothing is posted, or NO_REPLY and one of thumbsup, laughing, heart or tada to leave that reaction on the message instead (on Slack, with the reactions:write scope). The word only works on a message that carries the note; anywhere else it is posted as written. A turn nobody asked for (listens: judge) uses the same word.

  • A message sent while you work reaches you at your next tool step: it appears after the results of the tool you were running, as a user message in the same turn, and your reply should answer it along with the original ask. One that arrives while you write your final reply waits, and your next turn answers everything that queued. On Claude through a subscription sign-in it arrives with the result of the tool you were running, as a note that the user sent a new message while you worked. On Codex, messages always wait for the next turn.

  • With several agents in one thread, an untagged reply goes to whoever spoke last. Mentioned partway into a thread, your first turn opens with what was said before it (the parent and the newest earlier messages, marked as overheard). Mentioned at the top of a channel, it opens with the channel's 20 most recent messages. Both need the Slack app's history scopes. Without them you hear the thread from the mention onward only. Under admit: "principals", the default, earlier messages from people or integrations you don't admit are left out. Under admit: "anyone" a stranger among them lowers the conversation's trust as if they had spoken to you. Another of your fleet's agents, or a remote agent you answer, is kept and lowers it too.

  • Answering only when mentioned, never to thread replies, has three causes, and they are worth checking in this order:

    1. listens: mentions in your soul.
    2. Another agent spoke last in that thread. That includes an agent on another daemon or machine, and any other app's bot that posted there.
    3. The app is missing the history scopes (channels:history, groups:history, mpim:history) and their message.* events, which means adding them and reinstalling the app.

    Not answering at all also happens when the daemon is down, the app was removed from the channel, or the person is not in principals.slackUsers and admit is principals, the default.

  • Another agent on Slack needs its own soul on the roster first, then its own Slack app through stratus setup → Channels or stratus channel set slack --agent <id>. Saving the tokens connects it on a daemon that already serves Slack, with no restart; a daemon that started with no Slack app at all needs a restart.

  • Who counts as your operator is principals.slackUsers in the trusted config. By default (admit: "principals") everyone else is turned away before a turn starts, so an agent that says nothing to someone may simply not admit them. Under admit: "anyone" their messages reach you labelled as from someone unknown. Your operator changes the list, and who may approve (approvals.slackApprovers), with stratus people add <member-id> or stratus people remove <member-id> (--agent <id> for one agent's own list, --approver for approvers); stratus people lists them. A running daemon applies it with no restart.

  • How your replies look is your operator's slack.replies setting (or slack.agents.<your id>.replies), applied on restart: final, the default, posts each finished message; stream edits a … placeholder as you write; texts posts each paragraph of a reply as its own message, at most five, keeping code blocks, lists and a heading with its paragraph whole. Under texts, short paragraphs read like texts; a long one is still one long message.

  • A card in the thread comes from a tool whose result carries a post (text plus Slack blocks), such as plugin-json-render's. It posts in your thread in order with your words and files, and you are shown only its text afterwards. A card's buttons can only open links, its mentions notify nobody, and blocks Slack or the daemon won't take (inputs, menus, a button with no link) send its text alone. A post from an MCP server or other external result is never sent. Other channels show the text; the CLI shows nothing.

  • Images need the files:read scope. Text files are read up to a size limit, and anything else reaches you by name only, with the reason. The message says which. Files are only downloaded from Slack's own file host, so a remote file (another app's link to a file hosted outside Slack) reaches you by name only, as not downloaded.

The daemon

  • Running it. stratus serve runs the daemon in the foreground. stratus service install keeps it always on, with stratus service start|stop|status|uninstall. There is no top-level stratus start, stop, or status, and no stratus service restart.
    • On macOS it is a LaunchAgent, started at login, not at power-on. --no-login also gives up restarts after a crash.
    • On Linux it is a systemd user unit, which needs loginctl enable-linger to run while nobody is logged in.
    • A Linux machine where systemd isn't running (most containers, Docker and Fly machines, WSL without systemd) has no service: setup shows Always on as not available, stratus service refuses with that reason, and stratus serve runs under whatever supervises the machine.
    • It will not start while the trusted config (~/.stratus/config.json, or the file --config names) fails to parse or validate. The error goes to stderr, not stratus logs: on Linux the unit then shows as failed until the file is fixed and stratus service start is run; on macOS launchd keeps retrying until the file is fixed. A daemon already running keeps its last good config, and stratus service start, stratus update, and stratus restart refuse up front with the same error rather than restart into it.
  • stratus restart drains in-flight turns and restarts, and needs the control API. Slack stays connected during the drain: a message addressed to you then gets an "I'm restarting" reply and is answered by the next daemon (kept in slack-pending-intake.json, no text). Overheard messages during a drain are not kept. Without the API, use stratus service stop then stratus service start.
    • A restart is needed after a change to plugins (a plugin's env included), approvals, api, principals, slack, maxTurns, executor, or memoryStore, and after a channel plugin's new secrets. Two exceptions, once the daemon serves Slack: new Slack tokens connect without one, and who a Slack app answers to (principals.slackUsers, principals.admit, approvals.slackApprovers, approvals.slackChannel) applies at the next reload, which PUT /config does itself and POST /roster/reload does after a hand edit. Someone removed as an approver then cannot answer a request already posted.
    • It is not needed for soul edits, stored credentials and keys, the config's provider/model, or skills (stratus skill reload).
    • stratus update stops and starts the service itself. Only a daemon someone started with stratus serve needs restarting by hand.
  • In a container or under a system unit, stratus serve --log-format json also writes the log's records to stdout, for docker logs, journald, or a log shipper. stratus health is the probe.
  • stratus logs (-f to follow, --agent, --session) reads the structured log. It records that tools ran and sessions finished, never prompts or replies. One exception: a failed session keeps the provider's error text, so skim a log before sharing it.
    • A daemon that fails before it starts serving writes nothing there. Its error is in ~/.stratus/logs/stratusd.err.log on macOS, in journalctl --user-unit=stratusd.service on Linux, or on the terminal that ran stratus serve.
  • The control API is a separate install, @stratusagent/control-api, serving /api/v1 on 127.0.0.1:4123. The web dashboard is another, @stratusagent/dashboard, opened with stratus dashboard, which offers to install both when they are missing and starts a daemon if none is running.
    • From another device, the recommended path is a tunnel such as Tailscale, with api.publicUrl set to that address. A loopback daemon answers only to loopback names and that address, so a tunnel without it gets 403 host_not_allowed; credential links are built on it too.

When a reply stops or changes model

  • "Out of steps." One message may take maxTurns rounds of tool calls (default 40, set in the trusted config, restart to change). Past that you are told you are out of steps, get one last call with no tools, and answer with what you did and what is left. Replying "continue" carries on with a fresh allowance. An agent that does long work should have its own budget raised with agentMaxTurns: { "<id>": 300 } in the trusted config (restart to change), which leaves the rest of the fleet on maxTurns. With autoContinue: { "<id>": true } (or a number, to cap it) an agent is given another budget instead of stopping to ask, until its work is done, its cap is reached, or someone's message is still waiting when a budget runs out. On Slack, a message sent mid-task joins the running turn at its next step and shares the steps it has left: it renews neither the budget, the cap nor the repeated-failure count, and one that arrives after the steps run out waits and runs as its own turn. Under a kernel-driven provider (Anthropic, OpenAI-compatible), it also stops when stuck on a repeating failure; the claude-code runtime runs its own loop and does not have that check, so an uncapped claude-code agent relies on the cap or a new message to stop. It applies to the built-in model loop and the claude-code runtime, not yet to codex. A message that stopped this way is marked: stratus sessions shows it as completed (stopped early: turn limit) (or repeated failure), --status stopped-early lists only those, and the log has a session.stopped-early line. The mark clears when the next message in that conversation starts. On a recurring schedule, the next firing starts from that wrap-up: its message is the schedule's prompt followed by when and why the last run stopped and what it said was done and left, so carry on from there instead of starting over. From the second stop in a row it says so; post one line to the schedule's destination only if being stuck blocks someone, since the daemon doesn't post that for you.
  • A scheduled run's report that never arrived. A scheduled turn's final reply goes nowhere; only message.send reaches anyone. A send from a firing that the channel rejects, or that the approval policy refuses, ends its error by naming where your schedule reports, so send there. If the run ends with a send that failed or was refused and nothing reached the schedule's destination, the daemon posts one notice there itself: the schedule, the error's first line and the session id, never your reply.
  • Stopped with no reply. On a provider that streams (Anthropic, Codex, and plugin providers that say they stream), the daemon's watchdog aborts a turn when the model reports nothing for --idle-timeout seconds (default 120). On one that does not, such as an OpenAI-compatible endpoint, there is no watchdog, and a silent request ends only when the provider's own request fails or times out, so --idle-timeout neither explains nor fixes it. The session ends failed, with no activity for 120000ms, and stratus logs --agent <id> names the last thing it heard. A tool that is running, or a call waiting on an approval, never trips it.
  • Cut short by a restart. A daemon that stops or restarts gives running turns 30 seconds, then aborts the rest. A turn started by a Slack message, an event, or an API request that sent an idempotency key is left for the next start, which continues it once, and the person is told you will answer after the restart. Any other turn is marked failed ("stratusd is stopping" or "restarting"), and the message has to be sent again. On Codex or a Claude subscription sign-in, which run their own tool loop, every such turn is marked failed, since continuing could repeat what already ran.
  • Cut off at the output cap. A reply longer than the model may write in one turn fails rather than being posted half-finished, and the error says so. With an Anthropic API key that cap is maxTokens in the config (default 16000); a Claude subscription run does not take that setting, so raising it changes nothing there. Offer a shorter answer, or, on an API key, ask for the cap to be raised. Never ask for a longer timeout.
  • Answering on another model. When the configured model fails a request and a fallbackModel is set, that conversation switches to the fallback and stays on it, even across restarts, so it never silently swaps back. Not every failure switches: a conversation too long for the window is trimmed and retried on the same model, a cancelled turn just stops, and a Codex or Claude subscription turn that fails after its tools already ran surfaces the error instead, since a retry elsewhere would run them twice. Those leave the conversation on the configured model, with nothing to roll over. Your instructions say when you are on the fallback. stratus logs shows the error that caused the switch, and stratus session rollover <session id> starts the conversation over on the configured model.
  • When asked why a reply stopped, look before you answer if you can: with shell.run you can run stratus logs --agent <your id> (it is gated, so it needs approval or a granted scope), and an fs root covering ~/.stratus/logs/ lets you read the file. Without either, give the likely cause from what your transcript shows (an out-of-steps answer, a message with no reply, a request for a very long answer) and the log command that confirms it, rather than a certain answer.

Commands

Command What it does
stratus setup The menu: providers, models, an agent, plugins, channels (Slack, and any channel an enabled plugin contributes), approvals, always-on
stratus chat / stratus run Talk to an agent in the terminal, as a conversation or one prompt
stratus serve Run the daemon in the foreground
stratus service install|uninstall|start|stop|status Keep the daemon running as a background service
stratus restart Announced drain-and-restart of the running daemon
stratus logs Read the daemon's log
stratus health Whether the running daemon is serving: exit 0 if so, for a container healthcheck or a probe
stratus events What the events port has been doing, per source: the last delivery, the last refusal and why (a webhook with the wrong secret shows here, though it leaves no row), and the last 24 hours of deliveries and firings by status. --format json
stratus sessions (also stratus session list) The running daemon's sessions, newest first: status, agent, last update, id. --agent <id>, --status <status> (running, pending_approval, failed, completed, idle, or stopped-early for runs that ran out of steps and wrapped up), --limit <n> (default 20), --format json
stratus doctor What a run would use, and why
stratus update Update Stratus and its companion packages (--check to look first)
stratus dashboard Open the web dashboard with a one-time sign-in link
stratus agent new / stratus agents Create an agent / list the roster
stratus template add <path|owner/repo> Install a packaged agent: soul, skills, and plugin config
stratus skill add / stratus skill validate / stratus skill reload Install skills, check a skill directory, and have the running daemon re-read them
stratus skills List skills and which agents enable each
stratus plugins Every tool, its plugin, who is granted it, and what approvals do
stratus credential set / stratus credential remove / stratus credentials Store (value on stdin) or remove a named credential; list the names
stratus credential env <name> <VAR> --agent <id> Let that agent's shell commands read a stored key as <VAR>, from the next command; --remove undoes it
stratus signin set anthropic --agent <id> / stratus signin remove / stratus signin list (also stratus signins) Give one agent its own Claude subscription (token at a hidden prompt or on stdin), take it back, list who has one
stratus channel set / stratus channel remove / stratus channel list Store (values prompted without echo, or one per stdin line) or remove a channel's secrets for one agent; list which agents have them. A channel's secrets are never yours to read, and a running daemon picks them up at its next start
stratus grants <id> / stratus grants revoke <id> An agent's standing approvals, and taking one back
stratus schedules / stratus schedules cancel <id> Scheduled turns, and cancelling one
stratus tasks / stratus tasks cancel <id> The running daemon's background tasks: every one running or whose outcome isn't in its conversation yet, newest first, then the 20 most recently finished. Each line has the status, agent (and worker), start time, steps, task id, and whether the outcome is in the conversation; never what the task was asked or answered. --agent <id> (the agent that started them), --format json. cancel stops one from the CLI, and its conversation gets a notice that it was cancelled
stratus memory list / stratus memory search / stratus memory audit An agent's live memories with their trust labels, searched as the agent does, and every entry ever written
stratus memory forget / stratus memory pin / stratus memory unpin Retire entries, or keep them in the agent's instructions every turn
stratus memory export / stratus memory import Move an agent's memories to and from JSONL
stratus memory reassert Re-label memories' trust as the operator; no tool can
stratus memory passes / stratus memory undo-pass What memory passes did (status, counts, what each read; memory list --pass for what one wrote), and take one back
stratus session rollover <id> Start a long conversation over under the same id, keeping the old transcript
stratus version / stratus help The version, and the full reference for every flag

What you can do, and what to ask for

You can use the tools your soul grants, remember and recall, delegate to the agents you may delegate to, schedule turns and send messages if you have those tools, read your enabled skills, and ask for a named credential.

Your operator can do more from anywhere they reach the dashboard or the control API:

  • From the dashboard: edit souls (your tools:, credentials:, and listens: included), create agents, answer approvals, change the provider, model, and other settings, and store provider sign-ins and Slack tokens.
  • Through the control API as well: restart the daemon, revoke a grant, cancel a schedule, add a named credential, and edit approvals, principals, and api.

Only at the machine can someone:

  • enable or configure a plugin (its env and headers included), or choose an executor or memory store;
  • add an event source for schedule.on: an events block in ~/.stratus/config.json ("sources": { "github": { "verify": "github", "secrets": ["events.github"] } }), the secret stored with stratus credential set events.github, and a restart (docs/guides/events.md); or a plugin that feeds a source (a poller where no webhook can reach the daemon): its manifest declares the source id, enabling it in plugins is the whole setup, and stratus events names the plugin;
  • install or update packages;
  • remove a named credential, or replace one without an approver's form;
  • raise a memory's trust (stratus memory reassert).

When you need one of those, ask for it precisely, once: the exact command, the file and the line to change, and what it will let you do. For example: "In ~/.stratus/config.json, add "credentialsEnv": { "GH_TOKEN": "github.token" } under agents.kai in the @stratusagent/tool-shell block, then run stratus restart. After that, gh in my shell uses the github.token I already hold." Not a list of possibilities, and not a guess presented as a step.

Going further

Which version. You are not told which version of Stratus is installed. stratus --version says, and stratus update --check says whether a newer one exists. This skill ships with the installed version, so it describes what is running.

The full documentation is at https://github.com/stratuslabs/agent/tree/main/docs. It follows the latest code, which can be ahead of what is installed, so where a page and this skill disagree, this skill describes this install. Link a person to the page that answers their question rather than reciting it:

04 同仓库更多 Skills