Skip to content

[feature] Add Mast as an alert channel - #784

Merged
anilmurty merged 3 commits into
Metabuilder-Labs:mainfrom
ki7dk:feat/mast-channel
Oct 21, 2026
Merged

anilmurty merged 3 commits into
Metabuilder-Labs:mainfrom
ki7dk:feat/mast-channel

Conversation

@ki7dk

@ki7dk ki7dk commented Oct 17, 2026 •

Copy link
Copy Markdown
Contributor

Adds a seventh alert channel: Mast (https://tissue.systems/mast), a pager app for iPhone and Android. A channel key is the whole credential, so the config is one line. It's free: 2 channels and 1,000 messages a month with no account, 10 channels and 10,000 messages with a free Tissue account.

Disclosure: I run Tissue Systems, which operates Mast.

Summary

  • MastChannel in core/alerts.py, registered in _build_channel under type = "mast". One required setting, channel_url.
  • Severity maps onto three of Mast's four priorities: info → quiet, warning → normal, critical → loud. Nothing maps to page (repeats until acked, ignores mute and quiet hours); an optional priority = on the channel pins every alert to one level, and that's the only way to get page. A misspelt priority fails at startup, the same way a bad min_severity does.
  • Each send carries a dedupe key: the alert type plus a hash of the same (agent, type, scope) that CooldownTracker keys on, where scope is the session for cost_budget_session and session_duration. Both build it with one helper, so they can't drift apart. A repeat of the same alert inside the channel's dedupe window adds to the count on the card already on the phone; two sessions over their own budget get two cards.
  • Captured content is stripped per Critical Rule 5 unless alerts.include_captured_content is set, same as the other external channels.
  • A non-2xx response is logged with the error message from Mast's body, so a rotated key reads No such channel. rather than a bare 404. A 5xx gets different wording since the message may have been stored before the failure.
  • tj policy list shows channel_url=<set> and the priority, never the URL, since the URL carries the key.
  • Docs: a Mast section in docs/alerts.md (setup, both free tiers and their message quotas, the JSON fields with a link to Mast's sending docs, the priority mapping, page, the per-channel ceiling and how it interacts with the mapping on Android, and when repeats fold), the config example in docs/configuration.md, channel counts in docs/architecture.md and docs/nemoclaw-integration.md.

Tests / Verification

  • tests/unit/test_mast_channel.py (30 tests, httpx faked): factory registration, severity mapping, the page override and the startup check, payload fields, title/body caps, content stripping, and the logging for 4xx, 5xx, non-JSON and 202. For the dedupe key: repeats share a key, different agents and different sessions don't, long agent ids with a shared prefix don't collide, the key fits Mast's 120-character limit, and a table of alert pairs checks that the key agrees with CooldownTracker on what counts as the same alert.
  • tests/unit/test_cmd_policy.py: the channel summary redacts the key and shows the priority. tests/unit/test_config.py: the channel parses.
  • Full suite locally: 6046 passed, 17 skipped, 2 xfailed. The 2 failures are the test_xdist_isolation ones that fail the same way on main. ruff and mypy clean.
  • Exercised live against a real Mast channel through the real config loader, AlertEngine and dispatcher: critical, warning and info alerts arrived on the phone at loud, normal and quiet; a repeat folded into the existing card ("duplicate": true); a URL with a wrong key logged Mast rejected alert … (HTTP 404: No such channel.).
  • Wire contract: the JSON fields, the four priority values, the 250/4096/120 character caps, the error body shape and the 202 on store are documented at https://tissue.systems/mast/docs/send/.

What's NOT in this PR

  • No retry on failure. Mast answers 202 once the message is stored, and a retry after a 5xx could page someone twice, so a failure is logged and left at that.

Mast (https://tissue.systems/mast) is a push app that pages a phone, on
iPhone and Android. A channel key is the whole credential, so the channel
needs one setting, the channel URL:

    [[alerts.channels]]
    type = "mast"
    channel_url = "https://mast.tissue.dev/mk_..."

Severity maps onto three of Mast's four priorities: info sends quiet,
warning normal, critical loud. Nothing maps to "page", which repeats until
acked and ignores mute and quiet hours; you ask for it with priority= on
the channel. Each Mast channel also has a ceiling, set when it's created,
and a send above it is delivered at the ceiling. docs/alerts.md covers how
that interacts with the mapping, since a channel created on Android with
the default of Normal delivers critical alerts as normal.

Each send carries a dedupe key of "<type>:<agent>", the same pair
CooldownTracker keys on, so an alert that re-fires after the cooldown
lapses updates the card already on the phone instead of adding another.

Captured content is stripped per Critical Rule 5 unless
alerts.include_captured_content is set, like the other external channels.
Unlike them, a non-2xx response is logged, along with the error message
Mast puts in the body, so a rotated key shows up as "No such channel."
rather than a bare 404. A 5xx gets different wording because the message
may have been stored before it failed, and retrying could page twice.
@ki7dk
ki7dk requested a review from anilmurty as a code owner October 17, 2026 21:05
@greptile-apps

greptile-apps Bot commented Oct 17, 2026 •

Copy link
Copy Markdown
Contributor

RetriggerView in GreptileConfidence Score: 5/5

[Medium impact] The PR appears safe to merge; no actionable issues remain.

Summary

Adds Mast as a seventh alert channel.

  • Configured alerts now reach Mast with mapped priorities and matching-repeat keys.
  • Policy listings show Mast settings without revealing the channel key.

The previous unnumbered finding is fixed: the key now hashes the full identity instead of cutting off long agent IDs. No new actionable issues were found.

Diagram

%%{init: {'theme': 'neutral'}}%%
flowchart LR
  A[Alert] --> B[CooldownTracker]
  B --> C[AlertDispatcher]
  C --> D[MastChannel]
  D --> E[Strip captured content]
  E --> F[Map priority and build dedupe key]
  F --> G[Mast channel URL]
Loading

Reviews (3) · Last reviewed commit: "Merge branch 'main' into feat/mast-chann..." · Reviewed by Greptile

Comment thread tokenjam/core/alerts.py Resolved
@anilmurty

Copy link
Copy Markdown
Contributor

Thanks @ki7dk — first PR here, and the craft is well above average: the channel slots into the existing AlertChannel interface without touching the dispatcher, the content stripping goes through the same _format_detail_text(strip=...) path as ntfy/Discord/Telegram, the priority typo fails at construction rather than as a 400 six hours later, and tj policy list redacts the key. The test file is the best part — I broke strip=not self._include_captured_content to strip=False and test_captured_content_is_stripped_by_default failed, so the Critical Rule 5 guard is load-bearing rather than decorative. The docs are genuinely useful, especially the per-channel ceiling note.

I'm not merging yet. One disclosure question, one code finding, and a couple of accuracy nits.

1. Affiliation. The commits are authored from denis@tissue.systems, and Mast is operated by Tissue Systems. If you're the operator or otherwise affiliated, please say so in the PR body. That isn't a problem by itself — vendor-authored integrations are normal — but it changes how I weigh an integration for a service I can't independently verify, and I'd rather it be stated than inferred.

2. The dedupe key folds alerts that the cooldown treats as separate. The PR body says the key is "the same pair CooldownTracker keys on", but the tracker's key is (agent_id, type, scope) where scope is the session_id for the session-scoped types (COST_BUDGET_SESSION, SESSION_DURATION). The Mast key is only type:agent. So two concurrent sessions of the same agent each legitimately fire a session-budget alert, and the second one overwrites the first card on the phone. Fan-out fleets are exactly the workload we care about, so this isn't a corner case. Greptile's P2 on the [:120] truncation is the same defect from a different angle — two long agent ids sharing a prefix collide. Both are fixed by keying on a stable hash of the full (type, agent_id, scope) tuple rather than a truncated concatenation.

3. The wire contract isn't publicly documented. The only posting interface I can find documented is curl -d "text" "https://mast.tissue.dev/mk_...?priority=page" — a plain-text body and a query parameter. This PR posts a JSON object with title, body, priority and key, and there's no Mast section in the docs site. You note you checked it against the server source, which I believe, but it means the contract has exactly one source and a change to it would show up for our users as a WARNING line in a log nobody reads. Publishing the JSON contract on the Mast docs site would fix that; short of it, I'd like the docs here to say the channel posts JSON and name the fields, so a broken page is diagnosable.

4. "Free, no account required." The Mast pricing section I read says the free tier is 10 channels and 10,000 messages a month and that sign-in (email, Google or GitHub) is required. docs/alerts.md nuances this well in the prose, but the inline comment in the config example states it flatly, and neither mentions the message quota — which matters for an alert channel that can fire on every ingest. Please reconcile.

5. Small correction. write_config does serialise [[alerts.channels]] — _serialise walks lists of dataclasses recursively. I round-tripped a mast channel through _parse → write_config → _parse and it came back equal, including channel_url and priority, and an existing config without the new keys still parses. So that's better than the PR body claims; just worth correcting so nobody acts on the note later.

On CI: nothing has run on this PR — the fork-approval gate has the workflows at action_required, so the only green check is guard. I ran it locally instead, on your branch rebased on current main: tests/unit/test_mast_channel.py (19 passed), plus the alerts, config, policy and tracked-secrets suites (114 passed), then the full tests/unit tests/synthetic tests/integration run under -n auto. The five failures I saw there (test_summarize_relocate, two in test_summarize_cli, two xdist-isolation ones) reproduce identically on main, so they're pre-existing and not yours. ruff check tokenjam/ and mypy tokenjam/ are clean.

One thing I want to be straight with you about: the open question I can't settle by reading code is whether we take a channel for a service this small. Discord, Telegram and ntfy carry no judgment on my part because they're ubiquitous or open; Mast would be the first integration here pointing at a service most of our users have never heard of, and an alert channel is an egress path out of a local-first tool. I'm not saying no. I'm saying that's the decision, and I'd rather make it in the open than bury it in a code review. Get me items 1 through 4 and I'll make the call.

@anilmurty

Copy link
Copy Markdown
Contributor

Settled the open question from my last comment: yes, let's take Mast. No change needed on your side for that, and no need to litigate the disclosure — I know Denis. Mentioning the affiliation in the PR body is still worth doing for everyone else reading the thread later, but it is a courtesy, not a condition.

Two things left, and only the first is a real bug.

1. The dedupe key drops the session scope

"key": f"{alert.type.value}:{alert.agent_id or 'unknown'}"[:120]

Our cooldown tracker keys on three things, not two (core/alerts.py:217):

scope = session_id if alert_type in _SESSION_SCOPED_ALERT_TYPES else ""
key = (agent_id or "", alert_type.value, scope or "")

_SESSION_SCOPED_ALERT_TYPES is {COST_BUDGET_SESSION, SESSION_DURATION}. For those two, the session id is part of the identity, because two concurrent sessions of the same agent can each legitimately breach their own session budget. Our engine treats those as two alerts. Mast would see one key and the second card would replace the first on the phone.

That matters more for us than it would for most tools: fan-out, where one agent runs many sessions at once, is the workload this product exists for. It is also the case where you most want the alert.

Including scope in the key fixes it. Hashing the tuple rather than concatenating also removes the truncation collision the bot flagged — two long agent ids sharing a prefix currently fold into one card at [:120].

2. "Free, no account required"

The PR body and the inline config comment both say this. Your published free tier asks for sign-in and caps at 10 channels and 10,000 messages a month. The prose in docs/alerts.md gets it right; the one-line comment is the one to fix, and the quota is worth naming since an alert channel that silently stops at a limit is a bad surprise.

Everything else checks out

Content stripping uses the same _format_detail_text(alert, strip=...) path as the other external channels, and that guard was mutation-tested rather than read: flipping it to strip=False turns the test red. The config round-trips, tj policy list shows channel_url=<set> and never the URL itself, nothing logs it, and the channel adds no dispatcher special-casing beyond one line in the factory.

The 5-second timeout and no-retry design are right for this path. Alert dispatch sits behind the post-ingest hooks, where a channel that blocks or retries would be holding up ingest.

CI still has not run — the fork-approval gate has the workflows at action_required. Anil will start it. Everything above is local, which is not a substitute for the 3.10/3.11/3.12 matrix.

Nice first contribution. The channel-interface conformance in particular is better than most.

@anilmurty

Copy link
Copy Markdown
Contributor

CI has now run and is green across the matrix: lint, 3.10, 3.11, 3.12, test-ts, version-lockstep, plus the contributor guard. That retires the "unproven" caveat in my review. The dedupe-key finding stands — it is a behaviour no current test covers, which is itself worth a test when you fix it.

The dedupe key was type:agent, cut at 120 characters. CooldownTracker
also keys on the session for cost_budget_session and session_duration,
so two sessions of one agent going over their own budget were two
alerts here and one card on the phone. Mast folds a repeat into the
existing card and keeps its text, so the second session never showed.
Cutting at 120 characters also merged long agent ids with a shared
prefix.

The key is now the alert type plus a hash of (type, agent, scope),
built by one helper that CooldownTracker uses too, so the two can't
drift apart again.

Docs: give both free Mast tiers and their message quotas, link Mast's
page for the JSON fields, and explain when repeats fold.
@ki7dk

ki7dk commented Oct 20, 2026

Copy link
Copy Markdown
Contributor Author

Thanks Anil, both fixed.

The key is now the alert type plus a hash of agent, type and scope, built by the same helper CooldownTracker uses now, so they can't drift apart. Two sessions of one agent get separate cards for the session-scoped types, and everything else still folds per agent and type. The key is 57 chars at most, so Greptile's truncation case goes away too.

It was a bit worse than you described. Mast doesn't replace the card on a repeat, it bumps the repeat count on the existing card and keeps its text. So the second session only ever showed up as a count on the first one.

Added a test that runs pairs of alerts through both the tracker and the key and checks they agree, plus tests for the session and long-id cases. docs/alerts.md now also says repeats only fold when they land inside the channel's dedupe window.

The config comment and the description list both free tiers now: 2 channels and 1,000 messages a month without an account, 10 and 10,000 with a free one. Mast doesn't stop delivering at the limit, it keeps accepting sends, and the docs say that too.

And yes, I run Tissue Systems. Added it to the description. I also took out the write_config note, you were right about that.

The JSON fields are documented at https://tissue.systems/mast/docs/send/ and docs/alerts.md links there now. Fair point that it was hard to find from the landing page, which only showed the plain-text curl. That page has the JSON example now.

@anilmurty

Copy link
Copy Markdown
Contributor

Reviewed 52c6f95. This is a better fix than the one I asked for, and I owe you a correction on the second item.

The dedupe fix

I asked you to include the session scope. You extracted _alert_identity() and pointed both the cooldown tracker and the Mast key at it, so there is now one definition of "the same alert" instead of two copies that can drift. That is the right shape and it is what I should have suggested.

Verified rather than read:

  • The refactor changes no behaviour. I compared the old inline key against _alert_identity() across all 117 combinations of agent id, alert type and session id. Zero mismatches, so is_suppressed, record and hydrate all behave exactly as before.
  • Two sessions of one agent over budget now get two cards. Different keys, confirmed.
  • Non-session-scoped types still collapse across sessions, which is the behaviour you want to keep.
  • Long agent ids sharing a 120-character prefix no longer collide, which was the bot's angle on the same defect.
  • Keys are 52 characters, well inside Mast's 120 cap, so the truncation that caused the collision is gone rather than merely made less likely.

I also mutation-tested the new guard: dropping alert.session_id from the key turns test_two_sessions_over_budget_get_two_cards and the parametrised cooldown-parity test red. So those tests are load-bearing, not decorative. The parity test is the one I would keep forever — it pins Mast's key to the cooldown's notion of identity, so if someone changes one the other fails.

The free-tier item: I was wrong

I said your "free, no account required" line conflicted with a published tier requiring sign-in. Two errors in that.

The line I was looking at is the ntfy example, which predates your PR and is accurate — ntfy genuinely needs no account.

And your actual description is more precise than mine was: 2 channels and 1,000 messages with no account, 10 and 10,000 with one. I asserted sign-in was required. It is not. The new prose in docs/alerts.md, including what happens past the quota and the link to the limits page, is better than what I asked for. Nothing to change.

Where it stands

ruff and mypy clean. 48 tests pass across the Mast and alerts suites. Greptile is 5/5 on this head with the previous finding closed and nothing new.

Two mechanical things before merge, neither yours to fix:

  • The branch is behind main. It needs a merge or rebase; branch protection enforces up-to-date.
  • CI has not run on 52c6f95. The fork gate reset to action_required on the new push, so the green you may see is from the previous commit. Anil will start it.

Everything above is from my own machine, which is not the 3.10/3.11/3.12 matrix.

Good work on this one. The shared-identity helper is the kind of change that makes the next channel easier to add correctly.

@anilmurty anilmurty left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Approving. Thanks @ki7dk — and thanks for taking the dedupe note further than I asked.

CI is green across the matrix on 523f362, the branch is current with main, and Greptile is 5/5 with its finding closed and nothing new.

Re-verified on the merged head locally rather than relying on the earlier run: 125 tests pass across the Mast, alerts, config, policy and tracked-secrets suites; ruff and mypy clean.

What I checked by hand over the two rounds, so it is on the record:

  • Content stripping is load-bearing, not just present. Flipping strip=not self._include_captured_content to False turns the test red. Nothing beyond title, detail, priority and key reaches the wire, through the same path ntfy, Discord and Telegram already use.
  • The identity refactor changes no behaviour. The old inline cooldown key and the new _alert_identity() agree across all 117 combinations of agent id, alert type and session id, so is_suppressed, record and hydrate are untouched.
  • The session-scope guard is load-bearing too. Dropping alert.session_id from the key turns two tests red, including the parametrised one that pins Mast's key to the cooldown's own notion of identity. That test is the thing that stops the two drifting apart later.
  • The credential never leaks. tj policy list prints channel_url=<set>, error paths log only the alert id, status code and Mast's message, and nothing tracked carries a key.
  • The channel cannot hurt ingest. One line in the factory, no dispatcher special-casing, a 5-second timeout, no retries, and the dispatcher already wraps every send.

Over to @anilmurty to merge.

One note for whoever adds the eighth channel: _alert_identity() is now the single definition of "the same alert", and the parity test is what keeps a channel's dedupe key honest against it. Worth copying that pattern rather than hand-rolling a key.

@anilmurty
anilmurty merged commit 6aa8825 into Metabuilder-Labs:main Oct 21, 2026
6 checks passed
@ki7dk
ki7dk deleted the feat/mast-channel branch October 24, 2026 01:26
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants