on this page

MastChannels as Code

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/state returns 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}/rotate issues 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

  1. 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/state answer for your account instead of 404.

  2. 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:write
  3. Check what the token names before touching anything. A tok_ carries no account name a person would recognise, and channels called deploys and security would 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.json

The 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; PATCH with {"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": true on 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 202 and 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 deploy

CI. 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/null

When 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/null

Without 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. ribo has 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/state is the list, with devices, pairings and recent messages alongside.