Vitals
A vital is a channel that expects to hear from you. Beat it when a job finishes; if a beat doesn't arrive in time, mast pages the phone that owns the channel. That much has shipped and is covered under Mast and your Tissue account.
This page is about the rest of it: crontab schedules instead of a bare period, a start signal that catches the run which never finished, the beat history, the exit-code form of a beat, a dashboard screen that shows all of it, and a status badge you can paste into a README.
This is a preview. Everything here works on production and pages a real phone. What it doesn't have yet is a way to create a vital from the dashboard, so the screen only shows what a curl or the app already made. The URL isn't in the docs sidebar and the screen isn't in the dashboard nav — the link is here and nowhere else. Shapes on this page can still change.
Making one
There's no form for this yet. A vital is a channel with kind: "vital" and a period:
curl -X POST https://api.tissue.systems/v1/mast/channels \
-H "Authorization: Bearer $TISSUE_TOKEN" -H "Content-Type: application/json" \
-d '{"name": "nightly-backup", "kind": "vital",
"period_secs": 86400, "grace_secs": 3600, "vital_priority": "loud"}'The app can make one too: New channel, then Vital instead of the ordinary kind.
Every route below takes either the phone's own credential or a Tissue API token, and the token needs mast:read to read and mast:write to publish a badge. If the account has never been connected to mast, the read answers 404 with This account is not connected to mast. — connect it first.
period_secs is how often you promise to beat. grace_secs is how late that may be before anyone is woken. Silence past period_secs makes the vital late and does nothing else; silence past period_secs + grace_secs makes it flatline and sends a page at vital_priority. The next beat closes that page. A vital is only swept while it watches something. Set period_secs, schedule and max_duration_secs all to 0 or "" and nothing sweeps it, so it never goes late — that's how you park one without deleting it. Clearing only period_secs on a vital that also has a schedule leaves the schedule watching.
A vital can watch three different things, and it needs at least one of them:
| Field | Watches | Fails when |
|---|---|---|
period_secs |
How long between beats | Nothing heard for longer than the period allows |
schedule |
That the beat lands on a crontab | A fire came and went with no beat behind it |
max_duration_secs |
How long one run may take | A run opened and is still open past the limit |
period_secs and schedule answer the same question two ways, so a vital that sets both is judged on the schedule and the period is ignored. max_duration_secs is independent and stacks with either.
Each plan includes a number of vitals. Past it, a create answers 402 with plan_limit_reached and says which plan and how many — archive one or move up a plan. Beats, history and badges are never rationed; the count is of live vitals on the account.
Beating it
The plain beat is a POST to the channel URL with nothing in it, which is what lets a crontab line be one curl:
17 3 * * * /usr/local/bin/backup.sh && curl -fsS https://mast.tissue.dev/mk_8e2a… >/dev/nullWith the exit status
&& only beats when the job succeeds, and says nothing at all when it fails — the vital waits out its grace window before anyone hears about it. Put the exit status in the path instead and the failure is reported the moment it happens:
17 3 * * * /usr/local/bin/backup.sh >/tmp/backup.log 2>&1; \
curl -fsS -m 10 --data-binary @/tmp/backup.log \
"https://mast.tissue.dev/mk_8e2a…/$?" >/dev/null$? is the shell's exit status of the command before it, so the route sees 0 on success and the real status on failure:
| Path | What happens |
|---|---|
POST /mk_…/0 |
A beat. The vital stays alive and nothing rings, whatever the body says |
POST /mk_…/17 |
A flatline, right now. The card reads nightly-backup exited 17 |
POST /mk_…/fail |
The same flatline with no status to name. The card reads nightly-backup failed |
0 is a beat even when the body carries warnings. A job that prints to stderr on a good run shouldn't wake anybody, so on this route the exit status is the whole message and the text is only logged.
The code has to be an integer from 0 to 255. Anything else — abc, 256, -1, an empty segment — answers the same flat 404 an unknown key gets, because the caller holds a key and nothing else, and a more specific refusal would describe the channel behind it.
Whatever you send as the body is kept as the job's output: up to 16 KiB on the wire, of which the history keeps the first 4096 characters. --data-binary @file is the usual way to hand it a log.
From a Cell
The beat belongs in the pulse handler, on the path that finished:
export default {
async pulse(event, env) {
const rows = await sweep(env);
await fetch(`${env.VITAL_URL}/0`, { method: "POST", body: `swept ${rows} rows` });
},
};A throw before that fetch sends nothing, and the vital flatlines on its own once the grace window closes.
From GitHub Actions
A scheduled workflow is a common thing to put a vital on, and GitHub does one thing to it that is worth knowing before the first flatline. In a public repository, GitHub disables a workflow's schedule trigger after 60 days with no commit to the repository. Tags, issues, releases and merges from a fork do not count; only a push to a branch resets the clock. The Actions tab shows no error, and the disable also stops the workflow's other triggers (workflow_dispatch included) until somebody re-enables it. Private repositories are not affected.
From our side a workflow disabled this way looks exactly like a job that died: the beats stop and the vital flatlines. We cannot tell the two apart, and we do not try. If the repository is public and the page says the vital went quiet a couple of months after the last commit, check the workflow's page on GitHub first; a yellow banner there means the fix is one click and the flatline was benign.
The recipe below beats a vital and keeps itself enabled without a commit. The re-enable call is the same endpoint the Actions tab's button uses, and calling it on a workflow that is still enabled resets the 60-day clock; the token the workflow already has can make the call once it is granted actions: write.
name: nightly
on:
schedule:
- cron: "17 3 * * *"
workflow_dispatch:
permissions:
contents: read
actions: write # the keepalive step
jobs:
nightly:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: ./nightly.sh
- name: Beat the vital
if: always()
run: |
curl -fsS -m 10 -X POST "${{ secrets.VITAL_URL }}/${{ job.status == 'success' && 0 || 1 }}" \
--data-binary "run ${{ github.run_id }} ${{ job.status }}"
- name: Keep the schedule enabled
if: always()
env:
GH_TOKEN: ${{ github.token }}
run: gh api --method PUT "repos/${{ github.repository }}/actions/workflows/nightly.yml/enable"if: always() runs both steps whether or not nightly.sh failed, and the exit-code segment tells the vital which it was. nightly.yml in the last line is the workflow's own file name. If you would rather not grant actions: write, gautamkrishnar/keepalive-workflow does the same thing with an empty commit instead, at the cost of one commit every so often in the history.
The vital's URL is a write capability; keep it in a repository secret, never in the workflow file, because a public repository's history is public forever and a key seen in it has to be rotated.
On a schedule instead of a period
A period is a stopwatch: beat every 86400 seconds, whenever those seconds start. That's the wrong shape for a job that runs at 03:17, because a beat at 03:17 on Monday and one at 03:16 on Tuesday are 86340 seconds apart and the stopwatch has nothing to say about it. A schedule is the crontab itself:
curl -X PATCH https://api.tissue.systems/v1/mast/channels/nightly-backup \
-H "Authorization: Bearer $TISSUE_TOKEN" -H "Content-Type: application/json" \
-d '{"schedule": "17 3 * * *", "schedule_tz": "America/Los_Angeles", "grace_secs": 1800}'Five POSIX fields — minute, hour, day of month, month, day of week — with ranges, lists and steps: */15, 0,30, 1-5, 17 3 * * 1-5. Day of week is 0 to 6 with Sunday 0, and 7 is also Sunday, which is what a line lifted straight out of a real crontab means. Names work too: mon, fri, jan.
schedule_tz is an IANA zone name — America/Los_Angeles, Europe/Berlin — or a fixed offset like +02:00, or empty for UTC. Prefer the name. A backup written as 17 3 * * * at a fixed -08:00 runs an hour off for eight months of the year; the name follows the clocks.
The two rungs are the same, hung off the last fire instead of a stopwatch. Past the fire the vital is late; past the fire plus grace_secs it flatlines, and the card names what it was waiting for — No heartbeat for 1200s; expected 03:17 America/Los_Angeles daily. A weekly vital stays quiet for six days on purpose: it's the fire that's compared against the last beat, not the clock.
A schedule needs a grace_secs above zero, and a create or patch that would leave it at zero is refused. On a period, zero is honest — the period is itself the wait. On a schedule it means "page the second the minute ticks", which is before the job has started, let alone reported. Give it the time the job actually takes plus room.
A schedule that doesn't parse pages nobody. The API refuses the ones it can see at write time, and the sweep treats anything it can't read as owing nothing — the edit that broke it was made at a desk and the page it would cause arrives at 03:00.
A run that takes too long
Everything above notices a job that stopped. None of it notices a job that started and never finished — the one that's been holding a lock for six hours. Say the run began:
curl -fsS -m 10 -X POST https://mast.tissue.dev/mk_8e2a…/start
/usr/local/bin/backup.sh >/tmp/backup.log 2>&1
curl -fsS -m 10 --data-binary @/tmp/backup.log "https://mast.tissue.dev/mk_8e2a…/$?"With max_duration_secs set, a run still open past that many seconds flatlines immediately — no late rung under it, because that number is you writing down the longest this job may honestly take:
curl -X PATCH https://api.tissue.systems/v1/mast/channels/nightly-backup \
-H "Authorization: Bearer $TISSUE_TOKEN" -H "Content-Type: application/json" \
-d '{"max_duration_secs": 5400}'The card reads nightly-backup is still running rather than stopped reporting, because those are different incidents with different first moves. An overrun outranks a silence: while a run is in flight the last beat is by definition older than the run, so a long job trips the silence threshold as a side effect of doing what it was told.
A start is not a beat. It moves neither last_ping_at nor the vital's state, and it pages nobody. A job that starts every hour and never finishes is exactly what a vital is for, and counting the start as proof of life would hide it for as long as the job kept starting.
| Answer | Means |
|---|---|
202 {"state": "started"} |
The run is open and the clock is running |
202 {"state": "running"} |
A start was already open — the previous run never reported an end |
running is not an error and doesn't reset the clock, so a retried curl can't rescue an overdue run by starting it again. It's also what a hung job looks like from the outside.
/start exists only on a vital; on an ordinary channel it answers the same flat 404 an unknown key gets. The body is the run's own label — a git sha, a dataset name — kept in the history and never turned into a card.
The beat that ends the run carries how long it took, which is where duration_ms comes from.
The beat history
last_ping_at is one column and answers "is it late". The history answers "when did it last actually run, and what did it say", which is the question you have after being paged:
curl -s "https://api.tissue.systems/v1/mast/channels/nightly-backup/pings?limit=50" \
-H "Authorization: Bearer $TISSUE_TOKEN"{
"pings": [
{
"id": "mpi_9c1e4b70d25a8f3691c04e7b2d5a8f13",
"at": "2026-09-18T03:19:44.207Z",
"kind": "beat",
"source_ip": "203.0.113.19",
"user_agent": "curl/8.7.1",
"exit_code": 0,
"duration_ms": null,
"body": "pg_dump: 41 tables, 1.9 GiB in 94s"
},
{
"id": "mpi_4a7f0b3ce918d26504af71b8e3c92d06",
"at": "2026-09-17T03:19:41.880Z",
"kind": "fail",
"source_ip": "203.0.113.19",
"user_agent": "curl/8.7.1",
"exit_code": 17,
"duration_ms": null,
"body": "pg_dump: error: connection to server failed"
}
],
"next_before": null
}Newest first. A flatline that came from silence has no sender and writes no row, so the gap in the list is the outage.
exit_code is null when the sender reported none, which is not the same statement as 0. A bare beat and POST /fail both report none; the exit-code route always reports one.
kind is beat, fail or start. duration_ms is the run length on the beat that closed a run opened by /start, and null on every other row — including a beat from a vital that never sends a start, which is most of them. It's measured on the server from the two stamps, so a job that reports from a machine with a wrong clock still gets an honest number.
Paging is by cursor. limit defaults to 50 and caps at 200; before takes a stamp and the query is strictly earlier than it, so two beats inside the same millisecond land on different pages rather than both repeating:
curl -s "https://api.tissue.systems/v1/mast/channels/nightly-backup/pings?limit=200&before=2026-09-17T03:19:41.880Z" \
-H "Authorization: Bearer $TISSUE_TOKEN"next_before is the cursor for the next call and is null once the page came back short, so a loop that pages until the cursor clears terminates.
Depth is fixed fleet-wide and isn't sold by plan: 200 beats per vital, and 30 days whatever the count says. The per-vital trim rides along with each write, and the age sweep is what reclaims a vital that stopped beating and would otherwise hold its last 200 rows forever. These rows live on mast's delivery cluster, which replicates in full to its smallest machine, so the bound is a capacity fact rather than a billing lever.
The web app
Vitals live in the Tissue Mast web app at tissue.systems/mastnew/app/vitals, not in the platform dashboard: every vital on the account, its state, how often it's meant to beat and when it last did. Clicking one opens its history, the badge controls and the key. You can create a vital there too.
A status badge
A badge is a public read of one vital's state. Mint the token:
curl -X POST https://api.tissue.systems/v1/mast/channels/nightly-backup/badge \
-H "Authorization: Bearer $TISSUE_TOKEN"{
"badge_token": "mb_3f9d1a4c7e0b25869d13c4a7f0e28b56",
"badge_url": "https://mast.tissue.dev/badge/mb_3f9d1a4c7e0b25869d13c4a7f0e28b56.svg",
"badge_json_url": "https://mast.tissue.dev/badge/mb_3f9d1a4c7e0b25869d13c4a7f0e28b56.json"
}| State | Badge reads | Colour |
|---|---|---|
alive |
up |
green |
late |
late |
yellow |
flatline |
down |
red |
| never swept, or not a vital | unknown |
grey |
The image is drawn by mast rather than fetched from anywhere, so a README on a network that can't reach a badge CDN still renders, and a badge outage can only ever be our outage. The label is the channel's name, cut at 40 characters. Responses carry Cache-Control: public, max-age=60, which is about the resolution the sweep behind the state has anyway.
The .json form is Shields' endpoint schema, so https://img.shields.io/endpoint?url=… renders it in whatever style the rest of the README already uses:
{ "schemaVersion": 1, "label": "nightly-backup", "message": "up", "color": "brightgreen" }A bare https://mast.tissue.dev/badge/mb_… with no extension is the image, which is what a paste into a README wants.
The token is a read capability and nothing else. It can't send, it doesn't name the channel's key, and it's revoked on its own. What it does expose is the channel's name and whether the thing behind it is up — that's what a badge is, and it's why one is minted on request instead of existing on every channel from birth.
Minting twice gives back the same token, because a badge URL gets pasted into places you don't control and a second mint that silently retired the first would break every copy. Replacing one is a revoke and a mint, which is the same two steps said out loud:
curl -X DELETE https://api.tissue.systems/v1/mast/channels/nightly-backup/badge \
-H "Authorization: Bearer $TISSUE_TOKEN"Every pasted copy 404s from then on, and a 404 renders as a broken image. An unknown token, a malformed one and a revoked one all answer the same way, so nobody reading a badge URL can learn whether it was ever real.
Not here yet
- No create or edit form in the dashboard. Vitals are made in the app or over the API.
- The app can't set a schedule or a run limit. Those are API-only for now; a vital that has them shows them in the dashboard and reads them back over the API, but the phone's own create form still offers a period.
- The badge is the only unauthenticated read. There's no status page that shows several vitals at once.
- A vital's history is per channel. There's no account-wide feed of beats.