Notifications: opsci notify¶
opsci notify [--backend NAME] [--project-root PATH] TEXT [FILE]
Sends a message, and optionally one file, to the project's user. Agents call it before a session ends or hands over, and when something needs the user. Three back ends ship:
| back end | what it does | setup |
|---|---|---|
file (default) |
writes each message as its own document in messages/ |
none |
notion (recommended) |
posts to the project's Feed in Notion and @mentions you | Notion |
slack |
posts to a Slack channel through your own Slack app | this page |
The file must be inside the project (or its main checkout, in a git worktree) once symlinks
are resolved, and never under a hidden directory of your home directory (~/.config,
~/.ssh, ...): an attachment leaves the machine, so a token file cannot be sent by mistake.
Such a path is refused with an error and nothing is sent.
Exit status: 0 sent; 2 misconfiguration or bad input (a missing or refused attachment, an
unknown back end, a bad config, a missing or too-open credentials file); 3 the Slack send failed.
When the selected back end is not file and fails, the message is also written to
messages/, so it is not lost, and the command still exits non-zero.
The file back end¶
This is what you get with no configuration. Each message is one markdown file:
messages/2026-09-23_101500_run-finished-all-good.md
messages/2026-09-23_101500_run-finished-all-good-2.md # second message in the same second
messages/2026-09-23_101500_run-finished-all-good_fig.png # a copy of the attached file
- The name is the local date and time, then the first words of the message.
- A file is never overwritten; a name already taken gets
-2,-3, ... - An attached file is copied next to the message and linked from it. Files over 50 MB are linked where they are, not copied.
messages/is at the project root. In a git worktree the messages go to the main checkout'smessages/, so they are all in one place and survive removing the worktree.messages/is git-ignored except its README: messages are for the user of this checkout, can contain site details, and attachments can be large. The project record islog/.
To put messages somewhere else (for example one inbox for all your projects), set
notify.file.dir (relative to the project, or a ~/ path).
Config¶
opsci notify reads the notify: section of two YAML files. The project file wins, key
by key:
- user level:
~/.config/opsci/config.yaml(or the file named by$OPSCI_CONFIG;~/.configis$XDG_CONFIG_HOMEif that is set); - project level:
config/site.local.yaml(git-ignored; seeconfig/site.example.yaml). In a worktree without one, the main checkout's is used.
notify:
backend: slack # file | slack | notion; default file
slack:
credentials_file: ~/.config/opsci/slack.env # this is the default
channel: C0123456789 # optional; overrides SLACK_CHANNEL
# ca_file: <CA bundle> only if the default certificate store cannot verify slack.com
# file:
# dir: messages
Put backend: slack in the user-level file to use Slack in all your projects. Neither file
may hold the token: opsci notify refuses a config with a key containing token or a value
that looks like a Slack token.
--backend NAME overrides the config for one call.
Setting up Slack¶
You create your own Slack app in a workspace where you can install apps. It gets a bot token that can post messages and upload files, and nothing else.
1. Create the app¶
- Go to https://api.slack.com/apps, choose Create New App, then From a manifest, and pick your workspace.
- Paste this manifest (YAML), then create the app:
display_information:
name: opsci notify
features:
bot_user:
display_name: opsci-notify
oauth_config:
scopes:
bot:
- chat:write
- files:write
settings:
org_deploy_enabled: false
Or create it From scratch and, under OAuth & Permissions → Scopes → Bot Token Scopes, add exactly these two:
| scope | used for |
|---|---|
chat:write |
chat.postMessage (the text) |
files:write |
files.getUploadURLExternal and files.completeUploadExternal (the file) |
Add no other scopes. The app needs no read scopes, no user token scopes, no event
subscriptions and no incoming webhooks. If you only ever send text you can leave out
files:write; sending a file will then fail with missing_scope.
How the scope names were checked: on 2026-09-23 against Slack's method reference pages
(docs.slack.dev, reference/methods/chat.postMessage,
reference/methods/files.getUploadURLExternal and
reference/methods/files.completeUploadExternal), which list chat:write and
files:write as the bot token scopes. The manifest fields were checked against
reference/app-manifest on the same day. Neither has been tried end to end by creating
an app; the manual test below does that.
2. Install it and get the token¶
- OAuth & Permissions → Install to Workspace, and allow.
- Copy the Bot User OAuth Token. It starts with
xoxb-. Do not paste it into a chat with an agent, a terminal command line, or any file other than the one in step 3. - In Slack, create or pick the channel for notifications and invite the app to it:
/invite @opsci-notify. With onlychat:writethe app can post only in channels it is a member of. - Get the channel ID (
C...): it is shown in the channel details (click the channel name), and it is the last part of the channel's link (Slack screens not checked here).
3. Store the token in a private file¶
mkdir -p ~/.config/opsci && chmod 700 ~/.config/opsci
( umask 077 && ${EDITOR:-nano} ~/.config/opsci/slack.env )
chmod 600 ~/.config/opsci/slack.env
The file contains (in an editor, so the token never goes through a shell command line or history):
SLACK_TOKEN=xoxb-...
SLACK_CHANNEL=C0123456789
opsci notify refuses the file if it is not a regular file owned by you, or if group or
others have any access to it (use mode 600). It also refuses a credentials
file inside the project, where it could be committed.
The token is read by opsci notify itself and sent only in the HTTPS Authorization
header. It is never passed on a command line (where other users on a shared machine can
read it with ps), never put in the environment (where every child process and any agent
that prints its environment would see it), and never printed; error messages replace it
with <token>.
Do not export SLACK_TOKEN in ~/.bashrc or anywhere else.
4. Point config at it and test¶
In ~/.config/opsci/config.yaml (all projects) or the project's config/site.local.yaml:
notify:
backend: slack
(add slack: {credentials_file: ...} only if the file is not at the default path). Then:
opsci notify "hello from opsci notify"
tests/run_all --run-manual -k real_slack # in the framework repo: sends one text and one file
5. Keep agents away from the file (recommended)¶
Claude Code can deny its agents read access to the file. In ~/.claude/settings.json add
to permissions.deny:
"Read(~/.config/opsci/slack.env)"
(This rule was not tested here.) This stops an agent from reading the token by accident. It does not stop a program running as you from reading it.
Codex does not consume Claude Code's permission rules. Keep the same private credential
file and let opsci read it internally; do not ask Codex to open it. The credentials-entry
helper refuses calls from Claude Code and Codex. This is an accidental-exposure guard,
not isolation from other programs running as your user.
Revoking and replacing the token¶
If the token may have leaked, revoke it on the app's OAuth & Permissions page at
https://api.slack.com/apps, reinstall the app to get a new token, and put the new token in
slack.env. Check the file mode is still 600. (The exact button names on that page were
not checked.)
Errors¶
| message | what to do |
|---|---|
credentials file not found |
create it (step 3), or fix notify.slack.credentials_file |
group or others have access |
chmod 600 <file> |
not_in_channel |
invite the app to the channel |
channel_not_found |
use the channel ID (C...), not its name; invite the app |
missing_scope |
add the scope from the table above and reinstall the app |
invalid_auth, not_authed |
the token is wrong or revoked; replace it |
Adding a back end¶
A back end is a subclass of opsci.notify.Backend with a name and a
send(text, attachment) method that returns a one-line description of where the message
went. Register it with the @register decorator; notify.backend: <name> selects it and
notify.<name>: is its config section. Raise NotifyError for misconfiguration and
DeliveryError for a failed send; neither message may contain a secret. Keep credentials in
a private file checked with check_private_file, as the Slack back end does.