Mast channels as code
The app creates a channel in three taps and hands you its URL. For one phone and a handful of senders that is the whole job. Past that, the set of channels is configuration: which ones exist, what each is for, how loud it is by default, which vital watches which cron job. Configuration belongs in a file next to the alert rules that use it, so that a wiped phone, a new laptop or a fresh environment is a re-run rather than an afternoon of tapping.
This page is that pattern: one catalogue file, one short script that reconciles mast against it, and the URLs read back from mast on every run instead of stored anywhere. It uses nothing beyond the /v1/mast/* routes on Mast and your Tissue account, which is the page to read first. The script below is a trimmed copy of the one that provisions this platform's own paging.
What the API gives you
- Creation is idempotent by name. Names are unique per owner, and creating a name that already exists answers
409. A catalogue keyed by name needs no state file: what is missing gets created, what exists gets compared. - The URLs are readable back.
GET /v1/mast/statereturns every channel with its live key and URL, so no file has to remember them. Nothing in the catalogue is secret, and the catalogue can live in git. - A channel made from a script is an ordinary channel. The app syncs when it opens, when it returns to the foreground, and every fifteen seconds while it is on screen, so a new channel is on the phone within a sync. Nothing marks where it came from.
- Rotation is one call with a week of overlap.
POST /v1/mast/channels/{id}/rotateissues a new key, and the previous one keeps accepting sends for seven days. - Account-token calls are recorded. A create, update, rotate or delete made with a Tissue token lands in the account's activity feed. The same action from the phone does not, because a device has no account to file it under.
Before the first run
Connect the account once, in the app. The phone creates the owner; the token cannot. Connecting is a one-time step and it is what makes
GET /v1/mast/stateanswer for your account instead of404.Mint a token with the two mast scopes. Only an owner or an admin of the account can hold them; a developer cannot.
ribo token create mast-provision --scope mast:read --scope mast:writeCheck what the token names before touching anything. A
tok_carries no account name a person would recognise, and channels calleddeploysandsecuritywould look entirely plausible in the wrong account while paging the wrong phone.curl -s https://api.tissue.systems/v1/me -H "Authorization: Bearer $TISSUE_TOKEN" | jq '{accountId, scopes}'
The script does the third step itself and refuses to continue on a mismatch.
The catalogue
A JSON file, committed with the alert rules that use it:
{
"channels": [
{"name": "deploys", "default_priority": "normal",
"quiet_start": 1320, "quiet_end": 420, "quiet_tz": "-07:00"},
{"name": "security", "default_priority": "page",
"repeat_secs": 120, "dedupe_window_secs": 900}
],
"vitals": [
{"name": "backup-db1", "period_secs": 86400, "grace_secs": 5400, "vital_priority": "loud"}
]
}Every field is optional except the name, and a field absent from an entry is left alone on every run. That is deliberate: the phone can mute a channel or set quiet hours, and a reconcile that reset those each time would make the app's own controls useless. A field that is present in the entry wins over the phone.
| Field | Default | Range |
|---|---|---|
name |
required | 1–32 characters of lowercase letters, digits, ., _, - and single spaces, starting with a letter or digit; unique per owner. Not changeable: a rename is a new channel with a new URL |
kind |
channel |
channel or vital. Not changeable |
default_priority |
normal |
quiet, normal, loud or page; a sender can override per message |
max_priority |
loud |
Read-only here. The channel's ceiling: a send asking for more is clamped down to it. Only a registered phone can raise it |
sound |
the app's default | a sound name from the app |
quiet_start, quiet_end |
-1 (no window) |
minutes past midnight; a window may wrap midnight |
quiet_tz |
UTC | a fixed offset such as -07:00, not a zone name |
dedupe_window_secs |
300 | 0–86400 |
storm_threshold |
20 | up to 10000 messages per storm_window_secs |
storm_window_secs |
300 | 0–86400 |
repeat_secs |
120 | 0–86400; how often an unacknowledged page repeats |
expire_secs |
3600 | 0–604800; how long an unacknowledged page keeps trying |
escalate_after_secs |
0 (off) | 0–86400; needs a paired partner set in the app |
period_secs |
0 | 0–2592000; a vital needs one, or it could never flatline |
grace_secs |
0 | 0–2592000; how late a beat may be before the vital pages |
vital_priority |
loud |
the priority of the flatline page |
A channel this script creates starts with its ceiling at loud, so a page sent to it arrives as a loud. That is deliberate — a token that can create channels should not be able to hand itself a pager — and it means an entry carrying "default_priority": "page" pages nobody until the ceiling is raised in the app, once, by hand. Raise it on the channels that are meant to wake someone; leave the rest at loud.
Escalation is to another person's phone, and pairing two phones is done in the app with a QR code, so the catalogue carries the delay and the app carries the partner.
The reconcile
Standard library only, so it runs wherever Python 3 is. It needs two environment variables: MAST_TOKEN, the tok_… from above, and MAST_ACCOUNT, the account id it must name.
#!/usr/bin/env python3
"""Reconcile mast channels against a catalogue.
Prints one JSON document on stdout: the name -> url map plus what changed.
Progress and warnings go to stderr, so the output can be parsed as is.
"""
import json, os, sys, urllib.error, urllib.request
API = "https://api.tissue.systems"
TOKEN = os.environ["MAST_TOKEN"]
ACCOUNT = os.environ["MAST_ACCOUNT"]
FIELDS = ("default_priority", "sound", "quiet_start", "quiet_end", "quiet_tz",
"dedupe_window_secs", "storm_threshold", "storm_window_secs", "repeat_secs",
"expire_secs", "escalate_after_secs", "period_secs", "grace_secs", "vital_priority")
def call(method, path, body=None):
data = json.dumps(body).encode() if body is not None else None
req = urllib.request.Request(API + path, data=data, method=method, headers={
"Authorization": "Bearer " + TOKEN, "Content-Type": "application/json"})
try:
with urllib.request.urlopen(req, timeout=30) as r:
raw = r.read()
return r.status, (json.loads(raw) if raw else {})
except urllib.error.HTTPError as e:
return e.code, json.loads(e.read() or b"{}")
def log(msg):
print(msg, file=sys.stderr)
def die(msg):
log("reconcile: " + msg)
sys.exit(1)
cat = json.load(open(sys.argv[1]))
# Refuse to act until the token names the expected account and both scopes.
status, me = call("GET", "/v1/me")
if status != 200 or me.get("accountId") != ACCOUNT:
die(f"token names account {me.get('accountId')!r}, not {ACCOUNT!r}")
scopes = me.get("scopes", [])
if scopes != "*" and not {"mast:read", "mast:write"} <= set(scopes):
die(f"token is missing mast:read or mast:write (has {scopes})")
status, state = call("GET", "/v1/mast/state")
if status == 404:
die("this account is not connected to a mast owner. Connect it in the app first.")
if status != 200:
die(f"GET /v1/mast/state answered {status}: {state}")
if not [d for d in state["devices"] if not d.get("invalidated_at")]:
log("reconcile: WARNING this owner has no live device. Sends will be accepted "
"and reach nobody. Re-adopt the phone with a device link code.")
live = {c["name"]: c for c in state["channels"] if not c.get("archived_at")}
urls, created, patched, rotated = {}, [], [], []
for kind in ("channel", "vital"):
for entry in cat.get(kind + "s", []):
name = entry["name"]
want = {f: entry[f] for f in FIELDS if f in entry}
if kind == "vital" and not want.get("period_secs"):
die(f"vital {name!r} has no period_secs, so it could never flatline")
have = live.get(name)
# state carries the live URL. A channel with no live key cannot receive
# a send at all; rotating it is the same call that recovers from a key
# deleted by hand.
url = have.get("url") if have else None
if have is None:
status, have = call("POST", "/v1/mast/channels", {"name": name, "kind": kind, **want})
if status != 201:
die(f"creating {name}: {status} {have}")
created.append(name)
url = have["url"]
elif have["kind"] != kind:
die(f"{name!r} exists as a {have['kind']}, and the catalogue calls it a {kind}. "
"Rename one of them: kind is not patchable.")
else:
changes = {f: v for f, v in want.items() if have.get(f) != v}
if changes:
status, have = call("PATCH", "/v1/mast/channels/" + have["id"], changes)
if status != 200:
die(f"updating {name}: {status} {have}")
patched.append(name)
if entry.get("rotate") or not url:
status, have = call("POST", f"/v1/mast/channels/{have['id']}/rotate", {})
if status != 200:
die(f"rotating {name}: {status} {have}")
rotated.append(name)
url = have["url"]
urls[name] = url
# In mast and not in the catalogue. Never deleted: the phone makes channels of
# its own, and this script must not be the reason one of them vanished.
wanted = {e["name"] for k in ("channels", "vitals") for e in cat.get(k, [])}
extra = sorted(set(live) - wanted)
log(f"reconcile: {len(created)} created, {len(patched)} updated, {len(rotated)} rotated")
for name in extra:
log(f" ? {name} is in mast and not in the catalogue (left alone)")
json.dump({"urls": urls, "created": created, "patched": patched,
"rotated": rotated, "extra": extra}, sys.stdout, indent=2)
print()MAST_TOKEN=tok_… MAST_ACCOUNT=acct_… python3 mast-reconcile.py mast-channels.jsonThe rules it follows, and why:
- It never deletes. A channel in mast that the catalogue does not name is reported and left alone. Deleting is
DELETE /v1/mast/channels/{id}, by hand, once you are sure;PATCHwith{"archived": true}retires a channel and keeps its messages. - It refuses a kind mismatch. Kind is not patchable, and archiving the channel to recreate it would change the URL every sender holds.
- It refuses a vital with no period. A vital that can never flatline is a dead-man switch that is quietly dead.
- It rotates on request.
"rotate": trueon an entry issues a new key on the next run; remove the flag afterwards, or the key rotates on every run. - It warns on zero live devices. Sends to an owner whose phone is gone answer
202and reach nobody, and a green run must not suggest otherwise.
Run it twice: the second run reports nothing created, nothing updated, and the same map.
Handing the URLs to senders
A channel URL is a bearer credential. Whoever holds it can page the phone, so it stays out of git and out of the catalogue, and each sender gets only the channels it sends to. The script prints the map; where each URL goes depends on the sender.
A host. An environment file, root-owned and mode 0600, sourced by the cron entries and unit files that send:
# /etc/mast.env
MAST_DEPLOYS=https://mast.tissue.dev/mk_…
MAST_BACKUP_DB1=https://mast.tissue.dev/mk_…A Cell. A vault binding, set before the deploy. A vault value missing at deploy time is stored as a placeholder and the Cell sends to it, so the order matters:
ribo vault set my-cell MAST_DEPLOYS --env MAST_DEPLOYS
ribo deployCI. A repository or environment secret, written from the map by the same job that runs the reconcile.
Daemons read their environment once at start, so a rotated URL reaches a long-running process only after a restart; cron lines and CI jobs pick it up on their next run. Keep the list of things to restart next to the catalogue. The seven-day overlap is what makes a rotation safe to roll out over a day, and it is also the trap: a consumer the rollout missed keeps working for a week and then stops with no error, because the old key is simply gone. A test send from each consumer after a rotation is cheaper than finding out in a week.
From Ansible
Two plays. The first runs on the control node, where the token is, and turns the catalogue into a map; the second renders each host's share of the map onto the host. Fleet hosts never see the token.
- name: Reconcile mast channels
hosts: localhost
gather_facts: false
tasks:
- name: Reconcile the catalogue
command: python3 files/mast-reconcile.py files/mast-channels.json
environment:
MAST_TOKEN: "{{ mast_token }}" # vault
MAST_ACCOUNT: "{{ mast_account }}"
register: mast
changed_when: >-
(mast.stdout | from_json).created or
(mast.stdout | from_json).patched or
(mast.stdout | from_json).rotated
no_log: true # stdout holds every URL
- name: Keep the map for the next play
set_fact:
mast_urls: "{{ (mast.stdout | from_json).urls }}"
- name: Render each host's channel URLs
hosts: fleet
become: true
tasks:
- name: Write /etc/mast.env
copy:
dest: /etc/mast.env
owner: root
mode: "0600"
content: |
{% for name in mast_channels_for_host %}
MAST_{{ name | upper | replace('-', '_') }}={{ hostvars['localhost'].mast_urls[name] }}
{% endfor %}
notify: restart mast consumers
handlers:
- name: restart mast consumers
service:
name: "{{ item }}"
state: restarted
loop: "{{ mast_consumers_for_host | default([]) }}"mast_channels_for_host is a per-host or per-group list of catalogue names; mast_consumers_for_host names the units that snapshot their environment. no_log on the reconcile task is what keeps the URLs and the token out of the play log, at the cost of the script's own progress lines; drop it while debugging, against a throwaway channel.
A CI job runs the same script the same way, with the token in a secret and the map written to the pipeline's secret store.
Vitals: beat on evidence
The catalogue holds a vital's schedule; the beat comes from the job. The rule that keeps a vital honest is to beat only after the job's own success, never from a timer that runs whether or not the job worked:
@daily backup.sh && curl -fsS "$MAST_BACKUP_DB1" >/dev/nullWhen the beat has to come from somewhere other than the job, make it conditional on the job's own evidence, such as the age of the file it writes on success:
*/15 * * * * find /var/lib/backup/last-ok -mmin -90 | grep -q . && curl -fsS "$MAST_BACKUP_DB1" >/dev/nullWithout that condition the vital proves that cron runs, which is not what you wanted to know.
One vital per thing that fails on its own. Three hosts each running the backup are three vitals, not one shared one, because a shared vital stays alive while any of them beats. A vital's clock starts when it is created: a daily vital with a 90-minute grace created at noon pages at 13:30 the next day unless something has beaten it, so create the vital and its beat in the same change.
When the phone is reinstalled
A wiped or reinstalled phone registers as a new mast owner with no channels, while the account stays attached to the owner it was connected to. Do not connect the fresh install to the account, which answers 409, and do not recreate the channels. Mint a device link code with the token instead:
curl -X POST https://api.tissue.systems/v1/mast/link-codes \
-H "Authorization: Bearer $TISSUE_TOKEN" -H "Content-Type: application/json" \
-d '{"purpose": "device"}'{"code": "…", "purpose": "device", "expires_at": "2026-09-02T18:40:00Z"}The code is good for five minutes. Open mast://link/<code> on the phone; a QR code of that URL on a laptop screen works. The phone joins the established owner and the throwaway one is retired. Every channel, key and URL survives, so nothing is re-rendered and nothing restarts. A reconcile run afterwards reports no changes and no device warning.
Where the token stops
- Connecting the account to an owner happens in the app, once. The owner is created by the phone.
- The channel routes are the interface.
ribohas no mast subcommands and the MCP server has no mast tools, which is why the reconcile is a script. - There is no route that lists channels on its own;
GET /v1/mast/stateis the list, with devices, pairings and recent messages alongside.