
Matrix Announcement
- 7 installs
- 4 repo stars
- Updated August 2, 2026
- netresearch/matrix-skill
Helps with ai & agent building tasks.
About
matrix-announcement is a Claude Code skill for ai & agent building. It helps solo builders move faster with AI-assisted coding.
- matrix-announcement
- AI & Agent Building
- AI-coding skill
Matrix Announcement by the numbers
- 7 all-time installs (skills.sh)
- Ranked #12,545 of 16,546 AI & Agent Building skills by installs in the Skillselion catalog
- Data as of Aug 4, 2026 (Skillselion catalog sync)
npx skills add https://github.com/netresearch/matrix-skill --skill matrix-announcementAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 7 |
|---|---|
| repo stars | ★ 4 |
| Last updated | August 2, 2026 |
| Repository | netresearch/matrix-skill ↗ |
What it does
Helps with ai & agent building tasks.
Files
Matrix Announcement
Content guidance for Matrix announcements; matrix-communication does the sending.
The five rules
1. One headline, one purpose. A Matrix message is a tweet, not a blog post. 2. Send `formatted_body` with the HTML subset. body stays as plaintext fallback. Never send only Markdown — clients are not required to parse it. 3. Lists beat paragraphs. If you're tempted to write "and also …", start a <ul>. 4. Wrap code. Inline <code> for commands, paths, version strings, IDs, env vars — every one of them. Multi-line snippets in <pre><code class="language-…">. 5. Layout > words → render an HTML card to PNG. Comparisons, dashboards, multi-row tables die in formatted_body.
Type tags (pick one — never stack)
| Tag | Meaning | Title example |
|---|---|---|
New skill | first public release | New skill: github-release-skill v0.2.0 |
Release | feature version | Release: jira-skill v3.12.0 |
Patch | bugfix-only | Patch: docker-development-skill v1.7.0 |
Digest | weekly / multi-skill roundup | Digest: skill ecosystem — week of 2026-04-22 |
Heads-up | breaking change, deprecation | Heads-up: matrix-skill v2 drops Python 3.8 |
Postmortem | incident summary | Postmortem: CI cache wipe 2026-04-25 |
RFC | proposal seeking feedback | RFC: unified checkpoint schema |
Glyphs
One leading glyph at most. Never trailing decoration, multi-emoji ladders, 🚀, or 🎉. Approved: 🤖 bot · 📦 release · 🔧 tooling · 🛡 security · ⚠️ heads-up · 📋 digest · 🔬 RFC · 🚑 hotfix · 🔥 postmortem · ✨ new capability (sparingly).
Pre-send checklist
- [ ] Title fits on one line in Element on a 1280-wide screen.
- [ ] First sentence states the change. No "we're excited to".
- [ ] Every URL wrapped in
<a>with destination-as-text. - [ ] Every entity is a link: issue keys (even mid-sentence), versions → release page, MRs/PRs as
project/path!N/org/repo#N, pipelines, commits. Status updates: one item per line, starting with the linked issue key, blank lines between items. - [ ] Every command, path, version is in
<code>. - [ ] Multi-line code in
<pre><code class="language-…">. - [ ] At most one prefix glyph; no trailing emoji; no celebration.
- [ ]
bodyis a real readable plaintext fallback, not stripped HTML. - [ ]
msgtype=m.noticefor unattended automation,m.textotherwise. - [ ] No
@roomunless it is an outage. - [ ] Layout-heavy → card image with text fallback, not
<table>informatted_body. - [ ] Length under 3000 chars or split into a thread.
References
- html-subset.md — allowed/banned tags, Markdown↔HTML,
data-mx-*attributes - structure.md — skeleton, section patterns, element-when-to-use, length budget,
m.textvsm.notice - glyphs.md — full glyph table with banned set
- image-cards.md — chromium → upload →
m.imagerecipe; image-pairing rules - threading.md — threads, mentions, edits, redactions
- anti-patterns.md — wall-of-text, emoji ladder, mention storm, inline URLs (with fixes)
- text-templates.md — drop-in
formatted_bodyskeletons per type tag - templates/ —
release-card.html(1200×630),weekly-digest.html(1200×1500),comparison.html(1200×900) - gallery.html — visual preview of every rule, the five worked examples, and the three templates
Sending: pass the composed message to matrix-communication (matrix-send-e2ee.py "$ROOM" "$MARKDOWN" [--notice]). The transport converts markdown to HTML using the rules in html-subset.md; pass --notice for unattended automation so other bots can't auto-reply (mutually exclusive with --emote). For hand-crafted formatted_body or m.image cards, call the homeserver API directly — recipe in image-cards.md.
{
"skill_name": "matrix-announcement",
"evals": [
{
"id": 1,
"eval_name": "type-tag-required",
"prompt": "Announce the v3.12.0 release of jira-skill in #releases:example.com.",
"expected_output": "Composes a message that begins with a single type tag (e.g. 'Release: jira-skill v3.12.0') and a leading glyph.",
"files": [],
"assertions": [
"First line includes one of: Release / Patch / Heads-up / Digest / Postmortem / RFC / New skill",
"First line includes at most one prefix glyph",
"Does NOT use 🚀 or 🎉",
"Does NOT begin with 'We're excited to'"
]
},
{
"id": 2,
"eval_name": "lists-not-prose",
"prompt": "Compose a release announcement listing four changes: ecosystem detection, version bumps, signed tags, narrative release notes.",
"expected_output": "Renders the four items as a <ul>, not as a paragraph with 'and also...'.",
"files": [],
"assertions": [
"Uses <ul> or <ol> with <li> for the four items",
"Does NOT pack all four into a single paragraph",
"Each list item is a complete bullet, not a fragment"
]
},
{
"id": 3,
"eval_name": "code-tag-discipline",
"prompt": "Tell the room how to install the github-release-skill plugin and which command it blocks.",
"expected_output": "Wraps every command, path, version string, and identifier in <code>.",
"files": [],
"assertions": [
"/install-plugin invocation wrapped in <code>",
"Blocked command (gh release create) wrapped in <code>",
"Skill name (github-release-skill) wrapped in <code>",
"Version strings wrapped in <code>"
]
},
{
"id": 4,
"eval_name": "no-raw-urls",
"prompt": "Include a link to the repository at https://github.com/netresearch/matrix-skill in the message.",
"expected_output": "Wraps the URL in <a href='...'> with destination-as-text (org/repo or similar), not 'click here'.",
"files": [],
"assertions": [
"URL is in href, not in body",
"Anchor text is the destination identity, not 'here' or 'click here'",
"Plaintext body keeps the raw URL alongside the identifier"
]
},
{
"id": 5,
"eval_name": "render-png-for-tabular",
"prompt": "Post a weekly digest covering 17 repos with old and new versions for each.",
"expected_output": "Renders templates/weekly-digest.html to PNG via headless Chromium and posts m.image, instead of inlining a 17-row table in formatted_body.",
"files": [],
"assertions": [
"Surfaces that >6-row tables don't render well in formatted_body",
"Uses chromium --headless=new with --window-size=1200,1500",
"Sends m.image with mxc:// URI",
"Pairs the image with a plaintext body fallback or a follow-up m.text"
]
},
{
"id": 6,
"eval_name": "headsup-for-breaking",
"prompt": "Announce that matrix-skill v2.0.0 drops Python 3.8.",
"expected_output": "Uses the 'Heads-up' tag with ⚠️ glyph; states migration steps and timeline; does NOT use the 'Release' tag for this.",
"files": [],
"assertions": [
"Type tag is Heads-up, not Release",
"Glyph is ⚠️, not 📦 or 🚀",
"Includes Migration step",
"Includes Timeline / sunset date"
]
},
{
"id": 7,
"eval_name": "m-notice-for-bots",
"prompt": "Send the release announcement automatically from CI without human review.",
"expected_output": "Sets msgtype to m.notice (not m.text) so other bots won't auto-reply. Uses the matrix-send-e2ee.py --notice flag (the transport's native support) instead of falling back to a raw API call.",
"files": [],
"assertions": [
"Surfaces m.notice vs m.text distinction",
"Recommends m.notice for the unattended/CI case",
"Uses the --notice flag on matrix-send-e2ee.py / matrix-send.py rather than a raw curl PUT"
]
},
{
"id": 8,
"eval_name": "thread-long-content",
"prompt": "Post the v2.0.0 release announcement plus 14 detailed migration notes.",
"expected_output": "Headline + 1-sentence summary as parent; 14 migration notes in a thread via m.relates_to: m.thread.",
"files": [],
"assertions": [
"Parent message stays under 3000 chars",
"Detailed notes go into thread replies",
"m.relates_to uses rel_type m.thread (not m.replace or no relation)"
]
},
{
"id": 9,
"eval_name": "no-mention-storm",
"prompt": "Notify the team that they should review PR #42 before Friday.",
"expected_output": "Names ONE owner with a reason, not @everyone or a list of five users.",
"files": [],
"assertions": [
"At most one user mentioned",
"Mention uses matrix.to URL, not plaintext @name",
"Includes m.mentions block",
"Does NOT use @room"
]
},
{
"id": 10,
"eval_name": "image-pairs-with-text",
"prompt": "Post a release-card PNG for github-release-skill v0.2.0.",
"expected_output": "Sends m.image AND ensures install command + repo URL are accessible as text — either in the image's body field or as a follow-up m.text.",
"files": [],
"assertions": [
"m.image body is full plaintext fallback OR a follow-up m.text exists",
"Install command is selectable as text somewhere",
"Does NOT post the image alone with body='release card'"
]
},
{
"id": 11,
"eval_name": "first-sentence-is-the-change",
"prompt": "Announce that the new automated-assessment-skill checkpoint runner now handles ten edge cases.",
"expected_output": "First sentence states the change directly. Second sentence states the consequence. No 'we're excited' / 'we're happy to share'.",
"files": [],
"assertions": [
"First sentence states change in concrete terms",
"Does NOT begin with 'We're excited' / 'We're happy' / 'I'm pleased'",
"Does NOT use 'blazingly fast', 'amazing', 'magnificent'"
]
},
{
"id": 12,
"eval_name": "use-h3-not-h1",
"prompt": "Add a section heading 'What's new' to the announcement.",
"expected_output": "Renders the heading as <strong>What's new:</strong> on its own line OR as <h3>, NOT <h1> or <h2>.",
"files": [],
"assertions": [
"Section heading uses <strong> or <h3>",
"Does NOT use <h1> or <h2>",
"First-level title remains the type-tag line, not a heading"
]
}
]
}
Matrix Announcement
Content-design guidance for coding agents posting into Matrix rooms — release notes, version bumps, weekly digests, breaking-change heads-ups, postmortems, RFCs, multi-skill pipeline summaries. Companion to matrix-communication in this repo (which transports the message you compose here).
Why this skill
A Matrix room with five active bots quickly becomes unreadable when each one posts walls-of-text or 🚀✨🎉 NEW RELEASE!!! 🎉✨🚀. This skill defines:
- The HTML subset Matrix clients actually render
- A type-tag system (
Release,Patch,Heads-up,Digest,Postmortem,RFC,New skill) so a busy room can be skimmed - Glyph rules — one leading glyph max, no rockets, no party emoji
- The
m.textvsm.noticechoice - When to render an HTML card to PNG instead of cramming layout into
formatted_body - Drop-in templates and a visual gallery
Features
- Slim SKILL.md — five rules + type-tag table + pre-send checklist, kept within 500-word target
- Six reference guides — html-subset, structure, glyphs, image-cards, threading, anti-patterns
- Seven drop-in `formatted_body` skeletons — release, new-skill, digest, heads-up, postmortem, RFC, patch
- Three rendered HTML card templates —
release-card.html(1200×630),weekly-digest.html(1200×1500),comparison.html(1200×900). Render headlessly with Chromium, upload, post asm.image. - Visual gallery —
references/gallery.htmlpreviews every rule, all five worked examples, and the three templates in one page
Installation
Via the Netresearch marketplace (recommended)
/plugin marketplace add netresearch/claude-code-marketplaceThen /install-plugin netresearch/matrix-skill. Ships alongside matrix-communication and matrix-administration.
Via release download
Grab the latest release and extract to ~/.claude/skills/matrix-announcement/.
Usage
The skill triggers on any agent-authored Matrix post longer than a single line. Examples:
"Announce the vX.Y.Z release in #releases:example.com"
"Post the weekly skill digest"
"Heads-up the team that matrix-skill v2 drops Python 3.8"
"Render a comparison card for the new auth flow and post it"The skill defines what to put inside the message; matrix-communication ships it.
Rendering a PNG card
chromium --headless=new --hide-scrollbars \
--window-size=1200,630 \
--screenshot=card.png \
"file://$(pwd)/skills/matrix-announcement/references/templates/release-card.html"Substitute {{PLACEHOLDER}} values in the template before rendering. Full upload + m.image recipe in references/image-cards.md.
Structure
matrix-announcement/
├── SKILL.md # AI instructions
├── README.md # this file
├── LICENSE # → ../../LICENSE-MIT (and CC-BY-SA-4.0)
├── evals/evals.json # behavior expectations
└── references/
├── html-subset.md # allowed/banned tags, Matrix-specific attrs
├── structure.md # skeleton, type tags, length budget
├── glyphs.md # iconography, banned set
├── image-cards.md # chromium → upload → m.image recipe
├── threading.md # threads, mentions, edits, redactions
├── anti-patterns.md # bad/good comparisons + worked example
├── text-templates.md # 7 drop-in formatted_body skeletons
├── gallery.html # visual preview of all rules + examples
└── templates/
├── release-card.html
├── weekly-digest.html
└── comparison.htmlLicense
Code: MIT. Documentation: CC-BY-SA-4.0. SPDX: (MIT AND CC-BY-SA-4.0). See repo-level LICENSE-MIT and LICENSE-CC-BY-SA-4.0.
Anti-patterns (with fixes)
✗ Wall of text
We're excited to announce that we've been working on something cool. The new
github-release-skill detects your project ecosystem (TYPO3, Node.js, Go, PHP,
skill repos) and finds all version files automatically, bumps versions
consistently, creates release branches, PRs, signed annotated tags...✓ Structured
🤖 New skill: github-release-skill v0.2.0
Releases that don't burn tag names, skip CI, or end up as unsigned lightweights.
What it does:
• Detects ecosystem & bumps versions across all manifests
• Creates branches, PRs, signed annotated tags — full flow
• Rewrites auto-generated notes into a narrative
What it blocks:
• gh release create / delete
• Lightweight tags (git tag without -s)
Install: /install-plugin https://github.com/netresearch/github-release-skill✗ Emoji ladder
🚀✨🎉 NEW RELEASE!!! 🎉✨🚀
We're SO excited to ship matrix-skill v1.20.0!!! 🔥🔥🔥✓ One glyph, one purpose
📦 Release: matrix-skill v1.20.0
Media download + E2EE decryption.✗ Inline raw URLs
See https://github.com/netresearch/peer-qa-review-skill for details.✓ Linked, destination-as-text
Repo: <a href="https://github.com/netresearch/peer-qa-review-skill">netresearch/peer-qa-review-skill</a>✗ Three-column ASCII table
| Skill | Old | New |
|-----------------|-----|------|
| jira-skill | 3.11| 3.12 |
| typo3-testing | 5.13| 5.14 |✓ HTML table — or, better, a digest card image
<table>
<thead><tr><th>Skill</th><th>From</th><th>To</th></tr></thead>
<tbody>
<tr><td><code>jira-skill</code></td><td>3.11</td><td>3.12</td></tr>
<tr><td><code>typo3-testing-skill</code></td><td>5.13</td><td>5.14</td></tr>
</tbody>
</table>For more than three rows, render templates/weekly-digest.html to PNG and post it as m.image instead.
✗ Mention storm
cc @alice @bob @carol @dave @everyone — please review✓ One owner, named for a reason
Owner: @sebastian — review by Friday for the v3.13 cut.✗ Unannounced breaking change
Release: matrix-skill v2.0.0
- many improvements✓ Heads-up tag, migration in the message
⚠️ Heads-up: matrix-skill v2.0.0 — breaking
Drops Python 3.8 (EOL). Minimum is now 3.10.
Migration: bump your venv before installing v2.
Old v1.x line continues to receive security patches until 2026-12-31.A worked example — three ways
✗ A paragraph
Hey team, just wanted to share that we shipped a new version of matrix-skill (v1.20.0) which now supports media download from Matrix rooms including E2EE-encrypted files, and also we updated jira-skill to v3.10.0 with attachment upload support, so now you can do the full pipeline of pulling images out of matrix rooms and attaching them to jira tickets, here's how it works...
◯ Text-only structured (acceptable)
<p>📦 <strong>New: Matrix → Jira attachment pipeline</strong></p>
<p>Media from Matrix rooms (incl. E2EE) can now flow into Jira issues.</p>
<p><strong>3 commands, full pipeline:</strong></p>
<pre><code class="language-bash"># 1. Find media in room
matrix-read-e2ee.py ROOM --json
# 2. Download & decrypt
matrix-download-e2ee.py ROOM $EVENT_ID --output /tmp
# 3. Attach to Jira
jira-attachment.py add PROJ-123 /tmp/image.png</code></pre>
<p><strong>Releases:</strong></p>
<ul>
<li><code>matrix-skill</code> v1.20.0 — media download + E2EE decryption</li>
<li><code>jira-skill</code> v3.10.0 — attachment upload, --reporter flag</li>
</ul>✓ Card image + short text (best)
1. Render templates/release-card.html with the title "Matrix → Jira", the three commands, and the two version pills. 2. Send m.image with that PNG. body is the plaintext version above. 3. Follow with a one-line m.text:
<p>Docs: <a href="…">matrix-skill v1.20.0</a> · <a href="…">jira-skill v3.10.0</a></p><!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width,initial-scale=1" />
<title>Matrix announcement guideline — gallery</title>
<style>
:root {
--ink: #0e1116;
--ink-soft: #44505d;
--ink-faint: #7d8896;
--paper: #fafaf7;
--line: #e3e1d8;
--accent: #b85c1d;
--bad: #8a3a2a;
--good: #2f6f3e;
--code-bg: #f4f2ec;
}
* { box-sizing: border-box; }
html { background: var(--paper); }
body {
margin: 0;
color: var(--ink);
font-family: ui-sans-serif, "Helvetica Neue", Helvetica, Arial, sans-serif;
-webkit-font-smoothing: antialiased;
line-height: 1.5;
}
.layout {
display: grid;
grid-template-columns: 240px 1fr;
min-height: 100vh;
}
nav {
position: sticky; top: 0; align-self: start;
height: 100vh;
padding: 40px 24px 40px 40px;
border-right: 1px solid var(--line);
background: var(--paper);
overflow-y: auto;
}
nav .brand {
font-family: ui-monospace, "JetBrains Mono", monospace;
font-size: 12px; letter-spacing: 0.12em;
color: var(--ink-faint); text-transform: uppercase;
margin-bottom: 20px;
}
nav h1 {
font-size: 18px; margin: 0 0 24px;
line-height: 1.2; letter-spacing: -0.01em;
font-weight: 600;
}
nav ol { list-style: none; padding: 0; margin: 0; }
nav li { margin: 6px 0; }
nav a {
color: var(--ink-soft); text-decoration: none;
font-size: 14px;
display: block; padding: 4px 0;
}
nav a:hover { color: var(--accent); }
nav .group {
font-family: ui-monospace, "JetBrains Mono", monospace;
font-size: 11px; letter-spacing: 0.1em;
color: var(--ink-faint); text-transform: uppercase;
margin: 24px 0 8px;
}
main {
padding: 56px 64px 120px;
max-width: 1080px;
}
h1.page {
font-size: 48px; letter-spacing: -0.02em;
margin: 0 0 8px; font-weight: 600;
text-wrap: balance;
}
.page-sub {
font-size: 20px; color: var(--ink-soft);
margin: 0 0 48px; text-wrap: pretty;
max-width: 720px;
}
section { margin: 64px 0; scroll-margin-top: 24px; }
section > h2 {
font-size: 14px; letter-spacing: 0.12em; text-transform: uppercase;
color: var(--ink-faint); margin: 0 0 8px;
font-weight: 600;
}
section > h3 {
font-size: 32px; letter-spacing: -0.01em;
margin: 0 0 16px; font-weight: 600;
text-wrap: balance;
}
section > p { font-size: 17px; color: var(--ink); max-width: 720px; margin: 0 0 16px; }
code, pre {
font-family: ui-monospace, "JetBrains Mono", "SF Mono", Menlo, monospace;
}
p code, li code, td code, th code {
background: var(--code-bg);
padding: 1px 6px; border-radius: 4px;
font-size: 0.9em;
}
pre {
background: var(--code-bg);
border: 1px solid var(--line);
border-radius: 6px;
padding: 16px 20px;
overflow-x: auto;
font-size: 13.5px; line-height: 1.55;
margin: 16px 0;
}
pre code { background: transparent; padding: 0; font-size: inherit; }
table {
width: 100%; border-collapse: collapse;
margin: 16px 0; font-size: 15px;
}
th, td {
padding: 10px 14px; text-align: left;
border-bottom: 1px solid var(--line);
vertical-align: top;
}
th {
font-size: 12px; letter-spacing: 0.08em; text-transform: uppercase;
color: var(--ink-faint); font-weight: 600;
}
/* Matrix message mock */
.room {
background: #fff;
border: 1px solid var(--line);
border-radius: 8px;
padding: 24px 28px;
margin: 16px 0;
}
.msg {
display: grid;
grid-template-columns: 40px 1fr;
gap: 16px;
padding: 12px 0;
}
.msg + .msg { border-top: 1px dashed var(--line); margin-top: 4px; }
.avatar {
width: 40px; height: 40px; border-radius: 50%;
background: linear-gradient(135deg, #d8c9b3 0%, #b85c1d 100%);
color: #fff; display: grid; place-items: center;
font-weight: 600; font-size: 14px;
font-family: ui-monospace, monospace;
letter-spacing: 0.04em;
}
.avatar.bot { background: linear-gradient(135deg, #2f6f3e 0%, #1d5a8a 100%); }
.msg-head {
display: flex; gap: 12px; align-items: baseline;
margin-bottom: 4px;
}
.msg-head .name { font-weight: 600; color: #6534ad; }
.msg-head .name.bot { color: #1d5a8a; }
.msg-head .ts {
font-size: 12px; color: var(--ink-faint);
font-family: ui-monospace, monospace;
}
.msg-head .badge {
font-size: 10px; letter-spacing: 0.1em; text-transform: uppercase;
background: var(--code-bg); color: var(--ink-faint);
padding: 1px 6px; border-radius: 3px;
border: 1px solid var(--line);
}
.msg-body { font-size: 15px; line-height: 1.5; color: var(--ink); }
.msg-body p { margin: 4px 0; }
.msg-body ul { margin: 6px 0; padding-left: 24px; }
.msg-body li { margin: 2px 0; }
.msg-body pre {
margin: 8px 0; font-size: 12.5px;
background: var(--code-bg);
border: 1px solid var(--line);
}
.msg-body a { color: #1d5a8a; text-decoration: underline; text-underline-offset: 2px; }
.msg-body code { background: var(--code-bg); padding: 1px 5px; border-radius: 3px; font-size: 0.9em; }
.msg.notice .msg-body { color: var(--ink-soft); font-style: italic; }
.msg.notice .msg-body code,
.msg.notice .msg-body pre,
.msg.notice .msg-body strong { font-style: normal; }
/* Bad / good comparison frames */
.compare {
display: grid; grid-template-columns: 1fr 1fr;
gap: 20px;
margin: 16px 0;
}
.compare > div {
border: 1px solid var(--line);
border-radius: 8px;
padding: 20px;
background: #fff;
}
.compare .label {
font-family: ui-monospace, monospace;
font-size: 11px; letter-spacing: 0.12em; text-transform: uppercase;
margin-bottom: 12px;
}
.compare .bad .label { color: var(--bad); }
.compare .good .label { color: var(--good); }
.compare .bad { background: #fdf6f3; border-color: #e8c8be; }
.compare .good { background: #f5f9f5; border-color: #c8d8c8; }
.compare pre, .compare .room { background: rgba(255,255,255,0.6); border-color: rgba(0,0,0,0.08); }
/* Image card preview frames */
.card-frame {
border: 1px solid var(--line);
border-radius: 8px;
overflow: hidden;
margin: 24px 0;
background: #fff;
}
.card-frame .card-meta {
padding: 12px 20px;
background: var(--paper);
border-bottom: 1px solid var(--line);
font-family: ui-monospace, monospace;
font-size: 12px; color: var(--ink-faint);
letter-spacing: 0.04em;
display: flex; justify-content: space-between;
}
.card-frame iframe {
border: 0;
display: block;
background: #fff;
}
/* Allowed-tags grid */
.tag-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 10px;
margin: 16px 0;
}
.tag-chip {
background: #fff; border: 1px solid var(--line);
border-radius: 5px; padding: 8px 12px;
font-family: ui-monospace, monospace;
font-size: 13px; color: var(--ink);
}
.tag-chip.banned {
background: #fdf6f3; border-color: #e8c8be;
color: var(--bad); text-decoration: line-through;
}
.tag-chip .note {
display: block; font-size: 10px; letter-spacing: 0.04em;
color: var(--ink-faint); margin-top: 2px;
text-transform: uppercase;
font-family: ui-sans-serif, sans-serif;
text-decoration: none;
}
/* Glyph table */
.glyph-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 0;
margin: 16px 0;
border: 1px solid var(--line);
border-radius: 6px;
overflow: hidden;
}
.glyph-row {
display: grid;
grid-template-columns: 56px 1fr 1.4fr;
align-items: center;
padding: 10px 14px;
border-bottom: 1px solid var(--line);
background: #fff;
font-size: 14px;
}
.glyph-row:nth-last-child(-n+2) { border-bottom: 0; }
.glyph-row .g { font-size: 22px; }
.glyph-row .name { font-weight: 600; color: var(--ink); }
.glyph-row .desc { color: var(--ink-soft); }
.glyph-row.banned { background: #fdf6f3; }
.glyph-row.banned .g { filter: grayscale(0.7); opacity: 0.6; }
.glyph-row.banned .name { color: var(--bad); text-decoration: line-through; }
.callout {
border-left: 3px solid var(--accent);
background: #fff;
padding: 16px 20px;
margin: 24px 0;
border-radius: 0 6px 6px 0;
font-size: 15px;
}
.callout strong { color: var(--accent); }
hr.thin {
border: 0; border-top: 1px solid var(--line);
margin: 56px 0;
}
.footer-note {
font-family: ui-monospace, monospace;
font-size: 12px; color: var(--ink-faint);
margin-top: 80px; padding-top: 24px;
border-top: 1px solid var(--line);
}
</style>
</head>
<body>
<div class="layout">
<nav>
<div class="brand">netresearch · skill</div>
<h1>Matrix announcement guideline</h1>
<div class="group">Reference</div>
<ol>
<li><a href="#tldr">TL;DR</a></li>
<li><a href="#event">Event anatomy</a></li>
<li><a href="#html">HTML subset</a></li>
<li><a href="#structure">Structure</a></li>
<li><a href="#elements">Element rules</a></li>
<li><a href="#glyphs">Glyphs & emoji</a></li>
<li><a href="#image">When to render to PNG</a></li>
<li><a href="#thread">Threads & mentions</a></li>
<li><a href="#anti">Anti-patterns</a></li>
</ol>
<div class="group">Examples</div>
<ol>
<li><a href="#ex-release">New skill</a></li>
<li><a href="#ex-version">Version bump</a></li>
<li><a href="#ex-digest">Weekly digest</a></li>
<li><a href="#ex-headsup">Heads-up</a></li>
<li><a href="#ex-pipeline">Pipeline</a></li>
</ol>
<div class="group">Templates</div>
<ol>
<li><a href="#tpl-release">Release card</a></li>
<li><a href="#tpl-digest">Weekly digest</a></li>
<li><a href="#tpl-compare">Comparison</a></li>
</ol>
</nav>
<main>
<h1 class="page">Matrix announcements that don’t suck</h1>
<p class="page-sub">A guideline for coding agents posting into Matrix rooms. Structured, scannable, no celebration emoji, no walls of text. Designed HTML cards when the layout matters more than the words.</p>
<!-- TLDR -->
<section id="tldr">
<h2>00 — TL;DR</h2>
<h3>Five rules</h3>
<ol>
<li><strong>One headline, one purpose.</strong> A Matrix message is a tweet, not a blog post.</li>
<li><strong>Send <code>formatted_body</code> with the HTML subset, always.</strong> <code>body</code> stays as a readable plaintext fallback.</li>
<li><strong>Lists beat paragraphs.</strong> If you write “and also…”, start a <code><ul></code>.</li>
<li><strong>Code in <code><pre><code></code> blocks.</strong> Commands, paths, version strings, IDs — every one of them goes in <code><code></code>.</li>
<li><strong>When the layout matters more than the words, render an HTML card to PNG.</strong> Tables, dashboards, comparisons, hero announcements.</li>
</ol>
</section>
<hr class="thin" />
<!-- Event anatomy -->
<section id="event">
<h2>01</h2>
<h3>What a Matrix message actually is</h3>
<p>An <code>m.room.message</code> event is a JSON object. Two fields decide rendering: <code>body</code> (plaintext fallback — required, used by notifications, search, IRC bridges) and <code>formatted_body</code> (HTML, only when <code>format</code> is set).</p>
<pre><code>{
"msgtype": "m.text",
"format": "org.matrix.custom.html",
"body": "Release: github-release-skill v0.2.0\nRepo: https://github.com/netresearch/github-release-skill",
"formatted_body":
"<p><strong>Release:</strong> <code>github-release-skill</code> v0.2.0<br/>Repo: <a href=\"https://github.com/netresearch/github-release-skill\">netresearch/github-release-skill</a></p>"
}</code></pre>
<p><strong>m.text vs m.notice.</strong> Use <code>m.notice</code> for unattended automation. Clients render it visually muted and bots are forbidden from auto-replying — this prevents bot-on-bot loops. Use <code>m.text</code> when an agent posts on behalf of a human who reviewed the message.</p>
<div class="room">
<div class="msg">
<div class="avatar bot">B</div>
<div>
<div class="msg-head"><span class="name bot">release-bot</span> <span class="ts">14:02</span> <span class="badge">m.notice</span></div>
<div class="msg-body"><p>📦 <strong>Release:</strong> <code>github-release-skill</code> v0.2.0 — signed annotated tags, CI-gated.</p></div>
</div>
</div>
<div class="msg">
<div class="avatar">SM</div>
<div>
<div class="msg-head"><span class="name">Sebastian Mendel</span> <span class="ts">14:05</span> <span class="badge">m.text</span></div>
<div class="msg-body"><p>I checked the new flow against three repos — works as advertised. Migration notes follow in a thread.</p></div>
</div>
</div>
</div>
</section>
<hr class="thin" />
<!-- HTML subset -->
<section id="html">
<h2>02</h2>
<h3>The HTML subset you can rely on</h3>
<p>The Matrix spec defines an explicit allow-list. Element, Cinny, FluffyChat all converge on roughly the same set. Treat this as the floor.</p>
<p style="font-size:13px;color:var(--ink-faint);text-transform:uppercase;letter-spacing:0.08em;margin-top:24px;">Allowed</p>
<div class="tag-grid">
<div class="tag-chip"><p> <br> <hr><span class="note">block flow</span></div>
<div class="tag-chip"><strong> <em> <b> <i> <u> <del><span class="note">inline emphasis</span></div>
<div class="tag-chip"><code> <pre><span class="note">code — class="language-…"</span></div>
<div class="tag-chip"><blockquote><span class="note">quote prior content</span></div>
<div class="tag-chip"><ul> <ol> <li><span class="note">lists</span></div>
<div class="tag-chip"><h3>–<h6><span class="note">prefer h3 for titles</span></div>
<div class="tag-chip"><table> <thead> <tbody> <tr> <th> <td><span class="note">basic tables only</span></div>
<div class="tag-chip"><a href><span class="note">https/matrix.to/mxc</span></div>
<div class="tag-chip"><span data-mx-color><span class="note">matrix-specific color</span></div>
<div class="tag-chip"><span data-mx-spoiler><span class="note">spoiler with reason</span></div>
<div class="tag-chip"><sub> <sup><span class="note">rare but allowed</span></div>
<div class="tag-chip"><mx-reply><span class="note">client-injected on rich replies</span></div>
</div>
<p style="font-size:13px;color:var(--bad);text-transform:uppercase;letter-spacing:0.08em;margin-top:24px;">Banned (silently stripped)</p>
<div class="tag-grid">
<div class="tag-chip banned"><script></div>
<div class="tag-chip banned"><iframe></div>
<div class="tag-chip banned"><form></div>
<div class="tag-chip banned"><style></div>
<div class="tag-chip banned">style="…"</div>
<div class="tag-chip banned"><img src=https://…></div>
<div class="tag-chip banned"><video></div>
<div class="tag-chip banned">class="…" (mostly)</div>
</div>
<div class="callout">
<strong>Markdown vs HTML.</strong> If your skill composes in Markdown, run it through a real converter (markdown-it, commonmark) to produce HTML. Do <em>not</em> send raw Markdown in <code>formatted_body</code> — most clients won’t parse it. Keep Markdown-ish flavor in <code>body</code> only.
</div>
</section>
<hr class="thin" />
<!-- Structure -->
<section id="structure">
<h2>03</h2>
<h3>The skeleton of every announcement</h3>
<p>Title prefix, subject, version. One-sentence why-this-matters. At most three sections. Footer with links and install command. Same shape every time — readers learn the rhythm and skim faster.</p>
<pre><code>[icon] [Type tag]: [Subject] [— optional version]
[one-sentence why-this-matters]
[Section heading 1]
- bullet
- bullet
[Section heading 2]
- bullet
[Footer: links / tracking ticket / install command]</code></pre>
<p><strong>Type tags</strong> work like <code>[SECURITY]</code> in email subjects. Pick one; do not stack.</p>
<table>
<thead><tr><th>Tag</th><th>Meaning</th><th>Title example</th></tr></thead>
<tbody>
<tr><td><code>New skill</code></td><td>first public release</td><td>New skill: <code>github-release-skill</code> v0.2.0</td></tr>
<tr><td><code>Release</code></td><td>feature version of an existing skill</td><td>Release: <code>jira-skill</code> v3.12.0</td></tr>
<tr><td><code>Patch</code></td><td>bugfix-only bump</td><td>Patch: <code>docker-development-skill</code> v1.7.0</td></tr>
<tr><td><code>Digest</code></td><td>weekly or multi-skill roundup</td><td>Digest: skill ecosystem — week of 2026-04-22</td></tr>
<tr><td><code>Heads-up</code></td><td>breaking change, deprecation</td><td>Heads-up: <code>matrix-skill</code> v2 drops Python 3.8</td></tr>
<tr><td><code>Postmortem</code></td><td>incident summary</td><td>Postmortem: CI cache wipe 2026-04-25</td></tr>
<tr><td><code>RFC</code></td><td>proposal seeking feedback</td><td>RFC: unified checkpoint schema</td></tr>
</tbody>
</table>
</section>
<hr class="thin" />
<!-- Elements -->
<section id="elements">
<h2>04</h2>
<h3>When to use which element</h3>
<table>
<thead><tr><th style="width:130px">Element</th><th>Use it for</th><th>Avoid for</th></tr></thead>
<tbody>
<tr><td><code><strong></code></td><td>the one word in a sentence the reader must not miss</td><td>every other word</td></tr>
<tr><td><code><code></code></td><td>commands, paths, identifiers, version strings, env vars, JSON keys</td><td>English nouns</td></tr>
<tr><td><code><pre><code></code></td><td>multi-line commands, JSON examples, diff snippets</td><td>one-liners</td></tr>
<tr><td><code><ul></code></td><td>2–7 unordered items</td><td>a list of one</td></tr>
<tr><td><code><ol></code></td><td>numbered steps where order matters</td><td>bullets that happen to have numbers</td></tr>
<tr><td><code><blockquote></code></td><td>quoting a user, an error, a previous decision</td><td>indenting for visual variety</td></tr>
<tr><td><code><table></code></td><td>tabular data ≥2 cols × ≥3 rows</td><td>two-column term–definition pairs</td></tr>
<tr><td><code><hr></code></td><td>separating an unrelated postscript</td><td>between every section (use spacing)</td></tr>
<tr><td><code><a></code></td><td>every URL — never paste raw URLs</td><td>“click here” link text</td></tr>
</tbody>
</table>
<p style="margin-top:24px"><strong>Code blocks: language hints.</strong> Element renders <code><pre><code class="language-bash"></code> with syntax highlighting. Use it. Common: <code>language-bash</code>, <code>language-python</code>, <code>language-json</code>, <code>language-diff</code>, <code>language-yaml</code>.</p>
</section>
<hr class="thin" />
<!-- Glyphs -->
<section id="glyphs">
<h2>05</h2>
<h3>Iconography & emoji</h3>
<p>One glyph at the front of the title, optionally one inline glyph per bullet. Never trailing decoration. Never celebrations. The glyph carries meaning — if it doesn’t, drop it.</p>
<div class="glyph-grid">
<div class="glyph-row"><span class="g">🤖</span><span class="name">bot</span><span class="desc">agent-authored announcement</span></div>
<div class="glyph-row"><span class="g">📦</span><span class="name">release</span><span class="desc">new feature version</span></div>
<div class="glyph-row"><span class="g">🔧</span><span class="name">tooling</span><span class="desc">infra, build, dev experience</span></div>
<div class="glyph-row"><span class="g">🛡</span><span class="name">security</span><span class="desc">CVE, hardening, lockdown</span></div>
<div class="glyph-row"><span class="g">⚠️</span><span class="name">heads-up</span><span class="desc">breaking change, deprecation</span></div>
<div class="glyph-row"><span class="g">📋</span><span class="name">digest</span><span class="desc">weekly / multi-skill roundup</span></div>
<div class="glyph-row"><span class="g">🔬</span><span class="name">RFC</span><span class="desc">proposal, request for comment</span></div>
<div class="glyph-row"><span class="g">🚑</span><span class="name">hotfix</span><span class="desc">urgent patch</span></div>
<div class="glyph-row"><span class="g">🔥</span><span class="name">postmortem</span><span class="desc">incident summary</span></div>
<div class="glyph-row"><span class="g">✨</span><span class="name">new capability</span><span class="desc">use sparingly</span></div>
<div class="glyph-row banned"><span class="g">🚀</span><span class="name">rocket</span><span class="desc">never. it’s a release, not a launchpad.</span></div>
<div class="glyph-row banned"><span class="g">🎉</span><span class="name">party</span><span class="desc">never. nobody is celebrating.</span></div>
</div>
</section>
<hr class="thin" />
<!-- Image cards -->
<section id="image">
<h2>06</h2>
<h3>When to render an HTML card to PNG</h3>
<p><code>formatted_body</code> handles prose, lists and short tables. It is <em>bad</em> at:</p>
<ul>
<li>Comparisons (before/after, plan tiers, client X vs Y)</li>
<li>Dashboards (test counts, coverage deltas, dependency health)</li>
<li>Timelines (release calendars, incident timelines)</li>
<li>Anything wider than ~3 columns</li>
<li>Color-coded status pills, badges, hero announcement cards</li>
</ul>
<p>For these: <strong>design an HTML card, render to PNG headlessly, upload, post as <code>m.image</code></strong>. The plaintext <code>body</code> becomes the searchable fallback.</p>
<pre><code class="language-bash"># 1. Render
chromium --headless=new --hide-scrollbars \
--window-size=1200,630 \
--screenshot=card.png \
"file://$(pwd)/card.html"
# 2. Upload to homeserver
MXC=$(curl -s -X POST \
-H "Authorization: Bearer $MATRIX_TOKEN" \
-H "Content-Type: image/png" \
--data-binary @card.png \
"$HOMESERVER/_matrix/media/v3/upload?filename=card.png" \
| jq -r .content_uri)
# 3. Send m.image
curl -s -X PUT \
-H "Authorization: Bearer $MATRIX_TOKEN" \
-H "Content-Type: application/json" \
-d "{\"msgtype\":\"m.image\",\"body\":\"…plaintext fallback…\",\"url\":\"$MXC\",\"info\":{\"mimetype\":\"image/png\",\"w\":1200,\"h\":630}}" \
"$HOMESERVER/_matrix/client/v3/rooms/$ROOM/send/m.room.message/$(uuidgen)"</code></pre>
<div class="callout">
<strong>Why HTML→PNG, not LLM-generated images.</strong> HTML/CSS gives pixel-precise control. Templates render deterministically every week — diffs are obvious, regressions catchable. Coding agents are great at HTML and bad at rasters. Lean into the strength.
</div>
<p><strong>Always pair the image with text.</strong> An <code>m.image</code> alone is hostile to screen readers, push notifications, search, and copy-paste. Either set <code>body</code> to the full plaintext fallback, or follow the image with a one-line <code>m.text</code> containing the install command and links.</p>
</section>
<hr class="thin" />
<!-- Threads & mentions -->
<section id="thread">
<h2>07</h2>
<h3>Threads, mentions, edits, redactions</h3>
<p><strong>Thread the long stuff.</strong> Headline as parent; extended notes, screenshots, and Q&A in replies via <code>m.relates_to: { rel_type: "m.thread" }</code>.</p>
<p><strong>Mentions go through <code>matrix.to</code></strong> — not plaintext <code>@name</code>:</p>
<pre><code>cc <a href="https://matrix.to/#/@sebastian:example.com">@sebastian</a></code></pre>
<p>Include an <code>m.mentions</code> block so notifications fire correctly. Reserve <code>{ "room": true }</code> for outages.</p>
<p><strong>Edits within ~5 minutes</strong> use <code>m.relates_to: { rel_type: "m.replace" }</code>. Older than that — reply instead; edits to old messages are silent.</p>
<p><strong>Redact</strong> only when content is wrong-and-harmful (leaked secret, mistargeted ping). Never to “clean up”.</p>
</section>
<hr class="thin" />
<!-- Anti-patterns -->
<section id="anti">
<h2>08</h2>
<h3>Anti-patterns & fixes</h3>
<div class="compare">
<div class="bad">
<div class="label">✗ Wall of text</div>
<div class="room">
<div class="msg">
<div class="avatar">SM</div>
<div>
<div class="msg-head"><span class="name">Sebastian Mendel</span> <span class="ts">13:09</span></div>
<div class="msg-body">
<p>We’re excited to announce that we’ve been working on something cool. The new github-release-skill detects your project ecosystem (TYPO3, Node.js, Go, PHP, skill repos) and finds all version files automatically, bumps versions consistently, creates release branches, PRs, signed annotated tags, and after CI publishes the release, rewrites the auto-generated notes into a proper narrative description, and it also blocks gh release create…</p>
</div>
</div>
</div>
</div>
</div>
<div class="good">
<div class="label">✓ Structured</div>
<div class="room">
<div class="msg notice">
<div class="avatar bot">B</div>
<div>
<div class="msg-head"><span class="name bot">release-bot</span> <span class="ts">13:09</span> <span class="badge">m.notice</span></div>
<div class="msg-body">
<p>🤖 <strong>New skill:</strong> <code>github-release-skill</code> v0.2.0</p>
<p>Releases that don’t burn tag names, skip CI, or end up unsigned.</p>
<p><strong>What it does:</strong></p>
<ul>
<li>Detects ecosystem & bumps versions across all manifests</li>
<li>Creates branches, PRs, signed annotated tags — full flow</li>
<li>Rewrites auto-generated notes into a narrative</li>
</ul>
<p><strong>What it blocks:</strong> <code>gh release create</code>, <code>gh release delete</code>, lightweight tags.</p>
<p><strong>Install:</strong> <code>/install-plugin github.com/netresearch/github-release-skill</code></p>
</div>
</div>
</div>
</div>
</div>
</div>
<div class="compare">
<div class="bad">
<div class="label">✗ Emoji ladder</div>
<div class="room">
<div class="msg">
<div class="avatar">SM</div>
<div>
<div class="msg-head"><span class="name">Sebastian Mendel</span> <span class="ts">14:00</span></div>
<div class="msg-body">
<p style="font-size:18px">🚀✨🎉 NEW RELEASE!!! 🎉✨🚀</p>
<p>We’re SO excited to ship matrix-skill v1.20.0!!! 🔥🔥🔥</p>
</div>
</div>
</div>
</div>
</div>
<div class="good">
<div class="label">✓ One glyph, one purpose</div>
<div class="room">
<div class="msg notice">
<div class="avatar bot">B</div>
<div>
<div class="msg-head"><span class="name bot">release-bot</span> <span class="ts">14:00</span> <span class="badge">m.notice</span></div>
<div class="msg-body">
<p>📦 <strong>Release:</strong> <code>matrix-skill</code> v1.20.0</p>
<p>Media download + E2EE decryption.</p>
</div>
</div>
</div>
</div>
</div>
</div>
<div class="compare">
<div class="bad">
<div class="label">✗ Inline raw URLs</div>
<pre><code>See https://github.com/netresearch/peer-qa-review-skill for details.</code></pre>
</div>
<div class="good">
<div class="label">✓ Linked, destination-as-text</div>
<pre><code>Repo: <a href="https://github.com/netresearch/peer-qa-review-skill">
netresearch/peer-qa-review-skill
</a></code></pre>
</div>
</div>
<div class="compare">
<div class="bad">
<div class="label">✗ Mention storm</div>
<div class="room"><div class="msg">
<div class="avatar">SM</div>
<div><div class="msg-head"><span class="name">Sebastian Mendel</span> <span class="ts">15:11</span></div>
<div class="msg-body"><p>cc <a href="https://matrix.to/#/@alice:example.com">@alice</a> <a href="https://matrix.to/#/@bob:example.com">@bob</a> <a href="https://matrix.to/#/@carol:example.com">@carol</a> <a href="https://matrix.to/#/@dave:example.com">@dave</a> @room — please review</p></div></div>
</div></div>
</div>
<div class="good">
<div class="label">✓ One owner, named for a reason</div>
<div class="room"><div class="msg">
<div class="avatar">SM</div>
<div><div class="msg-head"><span class="name">Sebastian Mendel</span> <span class="ts">15:11</span></div>
<div class="msg-body"><p>Owner: <a href="https://matrix.to/#/@sebastian:example.com">@sebastian</a> — review by Friday for the v3.13 cut.</p></div></div>
</div></div>
</div>
</div>
</section>
<hr class="thin" />
<!-- Examples -->
<section id="ex-release">
<h2>Example 01</h2>
<h3>New skill announcement</h3>
<p>Type tag at the front. Version inline. Two columns of bullets max. Install command in a code block at the bottom. No celebration, no rocket.</p>
<div class="room">
<div class="msg notice">
<div class="avatar bot">B</div>
<div>
<div class="msg-head"><span class="name bot">release-bot</span> <span class="ts">13:09</span> <span class="badge">m.notice</span></div>
<div class="msg-body">
<p>🤖 <strong>New skill:</strong> <code>github-release-skill</code> v0.2.0</p>
<p>Releases that look simple until they go wrong — burned tag names, lightweight tags without signatures, bypassed CI, or release notes that are just a list of PR titles.</p>
<p><strong>What it does</strong></p>
<ul>
<li>Detects ecosystem (TYPO3, Node.js, Go, PHP, skill repos) and finds all version files</li>
<li>Bumps versions consistently across <code>ext_emconf.php</code>, <code>composer.json</code>, <code>package.json</code>, <code>guides.xml</code></li>
<li>Creates release branches, PRs, signed annotated tags — full flow</li>
<li>Rewrites auto-generated notes into a narrative description</li>
</ul>
<p><strong>What it prevents</strong></p>
<ul>
<li><code>gh release create</code> — blocked by hook (lightweight, bypasses CI)</li>
<li><code>gh release delete</code> — blocked (doesn’t recover burned tags)</li>
<li>Tag deletion from remote — blocked, recommends patch version</li>
</ul>
<p><strong>Install:</strong> <code>/install-plugin https://github.com/netresearch/github-release-skill</code><br/>
<strong>Repo:</strong> <a href="https://github.com/netresearch/github-release-skill">github.com/netresearch/github-release-skill</a></p>
</div>
</div>
</div>
</div>
</section>
<section id="ex-version">
<h2>Example 02</h2>
<h3>Version bump — the everyday case</h3>
<p>One sentence summary. Three bullets max. Link the ticket.</p>
<div class="room">
<div class="msg notice">
<div class="avatar bot">B</div>
<div>
<div class="msg-head"><span class="name bot">release-bot</span> <span class="ts">11:24</span> <span class="badge">m.notice</span></div>
<div class="msg-body">
<p>📦 <strong>Release:</strong> <code>jira-skill</code> v3.12.0</p>
<p>Progressive-disclosure refactor: <code>SKILL.md</code> now routes to 8 feature-class references instead of inlining everything.</p>
<ul>
<li>New eval harness (stream-json + jq) with baseline vs post-refactor scoring</li>
<li><code>jira-qa-gather.py</code> bundles issue, description, comments, worklog, links into one call</li>
<li>Saves 4–5 separate API calls per QA review</li>
</ul>
<p><strong>Tracking:</strong> <a href="https://issues.example.com/browse/NRS-4380">NRS-4380</a> · <strong>Repo:</strong> <a href="https://github.com/netresearch/jira-skill">netresearch/jira-skill</a></p>
</div>
</div>
</div>
</div>
</section>
<section id="ex-digest">
<h2>Example 03</h2>
<h3>Weekly digest</h3>
<p>For multi-skill weeks, a card image is almost always better than a tall list. The text version below works as a fallback or thread post; the visual version comes from <a href="#tpl-digest">the digest template</a>.</p>
<div class="room">
<div class="msg notice">
<div class="avatar bot">B</div>
<div>
<div class="msg-head"><span class="name bot">release-bot</span> <span class="ts">13:51</span> <span class="badge">m.notice</span></div>
<div class="msg-body">
<p>📋 <strong>Skill ecosystem update</strong> — releases since 2026-04-22</p>
<p><strong>New skills this week</strong></p>
<ul>
<li><code>german-technical-writing-skill</code> v1.0.0 — German technical-writing patterns</li>
<li><code>typo3-a11y-skill</code> v1.1.0 — WCAG 2.1 AA patterns for TYPO3 v13/v14</li>
<li><code>typo3-frontend-patterns-skill</code> v1.1.0 — sticky header, lazy loading, language switcher</li>
</ul>
<p><strong>Feature releases</strong> (17 repos)</p>
<ul>
<li><code>jira-skill</code> v3.12.0 — progressive-disclosure refactor + eval harness</li>
<li><code>automated-assessment-skill</code> v2.8.0 — checkpoint runner hardened against ~10 edge cases</li>
<li><code>typo3-testing-skill</code> v5.14.0 — <code>Build/captainhook.json</code> standardised</li>
</ul>
<p><strong>Patches</strong></p>
<ul>
<li><code>git-workflow-skill</code> v1.13.1, <code>github-project-skill</code> v2.13.1, <code>php-modernization-skill</code> v1.15.1</li>
</ul>
</div>
</div>
</div>
</div>
</section>
<section id="ex-headsup">
<h2>Example 04</h2>
<h3>Heads-up: breaking change</h3>
<p>Always lead with the warning glyph. Always state the migration. Always state the timeline.</p>
<div class="room">
<div class="msg">
<div class="avatar">SM</div>
<div>
<div class="msg-head"><span class="name">Sebastian Mendel</span> <span class="ts">09:18</span> <span class="badge">m.text</span></div>
<div class="msg-body">
<p>⚠️ <strong>Heads-up:</strong> <code>matrix-skill</code> v2.0.0 — breaking</p>
<p>Drops Python 3.8 (EOL). Minimum is now 3.10.</p>
<p><strong>What changes:</strong> <code>matrix-read-e2ee.py</code> now uses <code>match</code> statements internally; install fails on 3.9.</p>
<p><strong>Migration:</strong> bump your venv before installing v2.</p>
<pre><code class="language-bash">pyenv install 3.10.14
pyenv local 3.10.14
pip install --upgrade matrix-skill==2.0.0</code></pre>
<p><strong>Timeline:</strong> v1.x continues to receive security patches until 2026-12-31.<br/>
<strong>Help:</strong> <a href="https://issues.example.com/browse/NRS-4421">NRS-4421</a></p>
</div>
</div>
</div>
</div>
</section>
<section id="ex-pipeline">
<h2>Example 05</h2>
<h3>Pipeline announcement (multi-skill)</h3>
<p>When a feature spans two skills, a single message tells the whole story. Code block in the middle, version pills at the bottom.</p>
<div class="room">
<div class="msg notice">
<div class="avatar bot">B</div>
<div>
<div class="msg-head"><span class="name bot">release-bot</span> <span class="ts">13:09</span> <span class="badge">m.notice</span></div>
<div class="msg-body">
<p>📦 <strong>New: Matrix → Jira attachment pipeline</strong></p>
<p>Media from Matrix rooms (incl. E2EE-encrypted files) can now flow into Jira issues.</p>
<p><strong>3 commands, full pipeline</strong></p>
<pre><code class="language-bash"># 1. Find media in room (JSON includes mxc URL + info)
matrix-read-e2ee.py ROOM --json
# 2. Download & decrypt
matrix-download-e2ee.py ROOM $EVENT_ID --output /tmp
# 3. Attach to Jira
jira-attachment.py add PROJ-123 /tmp/image.png</code></pre>
<p><strong>Releases</strong></p>
<ul>
<li><code>matrix-skill</code> v1.20.0 — media download + E2EE decryption</li>
<li><code>jira-skill</code> v3.10.0 — attachment upload, <code>--reporter</code> flag, user search</li>
</ul>
</div>
</div>
</div>
</div>
</section>
<hr class="thin" />
<!-- Templates -->
<section id="tpl-release">
<h2>Template 01</h2>
<h3>Release card — 1200 × 630</h3>
<p>Drop-in HTML for single-skill releases. Render headlessly, post as <code>m.image</code>. Substitute <code>{{TITLE}}</code>, <code>{{VERSION}}</code>, the bullets, the install URL.</p>
<div class="card-frame">
<div class="card-meta"><span>templates/release-card.html</span><span>1200 × 630</span></div>
<iframe id="tpl1" src="templates/release-card.html" width="1200" height="630" style="transform-origin:top left"></iframe>
</div>
</section>
<section id="tpl-digest">
<h2>Template 02</h2>
<h3>Weekly digest — 1200 × 1500</h3>
<p>Tall card for multi-skill roundups. Pill-prefixed rows (<code>new</code> / <code>feat</code> / <code>patch</code>) keep the eye moving. Render once a week, post with a one-line text summary.</p>
<div class="card-frame">
<div class="card-meta"><span>templates/weekly-digest.html</span><span>1200 × 1500</span></div>
<iframe id="tpl2" src="templates/weekly-digest.html" width="1200" height="1500" style="transform-origin:top left"></iframe>
</div>
</section>
<section id="tpl-compare">
<h2>Template 03</h2>
<h3>Comparison — 1200 × 900</h3>
<p>Before/after, vs-tables, migration deltas. Two color-coded panels. Use for breaking changes, RFC trade-offs, or “old way / new way” explainers.</p>
<div class="card-frame">
<div class="card-meta"><span>templates/comparison.html</span><span>1200 × 900</span></div>
<iframe id="tpl3" src="templates/comparison.html" width="1200" height="900" style="transform-origin:top left"></iframe>
</div>
</section>
<p class="footer-note">Skill: matrix-announcement · v1.22.0 · paired with SKILL.md</p>
</main>
</div>
<script>
// Populate templates with example data, then scale them down to fit the column.
// Build the substituted HTML in a local string and assign innerHTML once —
// assigning per-key would re-parse and re-serialize the DOM each iteration.
// split/join is also safer than String.prototype.replace with a string
// argument, which interprets `$&`, `$1`, etc. in the replacement.
function paint(iframe, fills) {
if (!iframe.contentDocument) return;
let html = iframe.contentDocument.body.innerHTML;
for (const [k, v] of Object.entries(fills)) {
html = html.split('{{' + k + '}}').join(v);
}
iframe.contentDocument.body.innerHTML = html;
}
function fitFrame(iframe, designWidth, designHeight) {
const wrap = iframe.parentElement;
const targetWidth = wrap.clientWidth;
const scale = targetWidth / designWidth;
iframe.style.transform = 'scale(' + scale + ')';
// Make the wrapper take only the scaled height, eliminating dead space.
iframe.style.width = designWidth + 'px';
iframe.style.height = designHeight + 'px';
wrap.style.height = (designHeight * scale) + 'px';
wrap.style.overflow = 'hidden';
}
function setup() {
const tpl1 = document.getElementById('tpl1');
const tpl2 = document.getElementById('tpl2');
const tpl3 = document.getElementById('tpl3');
tpl1.addEventListener('load', () => {
paint(tpl1, {
TYPE_TAG: 'NEW SKILL',
DATE: '2026-04-29',
ECOSYSTEM: 'TYPO3 / NODE / GO / PHP',
TITLE: 'github-release-skill',
VERSION: '0.2.0',
LEDE: 'Releases that don’t burn tag names, skip CI, or end up as unsigned lightweight tags.',
DOES_1: 'Detects project ecosystem & finds all version files automatically',
DOES_2: 'Bumps versions consistently across every manifest',
DOES_3: 'Creates branches, PRs, signed annotated tags — full flow',
PREVENTS_1: '<code>gh release create</code> — lightweight, bypasses CI',
PREVENTS_2: '<code>gh release delete</code> — doesn’t recover burned tags',
PREVENTS_3: 'Lightweight tags (<code>git tag</code> without <code>-s</code>)',
REPO_URL: 'github.com/netresearch/github-release-skill',
ORG: 'netresearch',
});
fitFrame(tpl1, 1200, 630);
});
tpl2.addEventListener('load', () => {
paint(tpl2, {
WEEK_START: '2026-04-22',
WEEK_END: '2026-04-29',
LEDE: 'Three new skills, six feature releases, eight patches. Highlights: progressive-disclosure refactor of <code>jira-skill</code>, hardened checkpoint runner in <code>automated-assessment-skill</code>.',
NEW_NAME_1: 'german-technical-writing-skill',
NEW_DESC_1: 'German technical-writing patterns for handbooks & release notes',
NEW_VER_1: '1.0.0',
NEW_NAME_2: 'typo3-a11y-skill',
NEW_DESC_2: 'WCAG 2.1 AA patterns for TYPO3 v13/v14',
NEW_VER_2: '1.1.0',
FEAT_NAME_1: 'jira-skill',
FEAT_DESC_1: 'Progressive-disclosure refactor; new eval harness (stream-json + jq)',
FEAT_VER_1: '3.12.0',
FEAT_NAME_2: 'automated-assessment-skill',
FEAT_DESC_2: 'Checkpoint runner hardened against ~10 edge cases',
FEAT_VER_2: '2.8.0',
FEAT_NAME_3: 'typo3-testing-skill',
FEAT_DESC_3: '<code>Build/captainhook.json</code> is now the Netresearch standard location',
FEAT_VER_3: '5.14.0',
PATCH_NAME_1: 'git-workflow-skill',
PATCH_DESC_1: 'GW-06 unified CODEOWNERS locations, GW-14 captainhook',
PATCH_VER_1: '1.13.1',
PATCH_NAME_2: 'github-project-skill',
PATCH_DESC_2: 'GH-08/09/19/20/24..27 reusable-workflow acceptance',
PATCH_VER_2: '2.13.1',
PATCH_NAME_3: 'php-modernization-skill',
PATCH_DESC_3: 'PM-02 Build/phpstan/ subdir, PM-18 transitive rector',
PATCH_VER_3: '1.15.1',
TOTAL_REPOS: '17',
TOTAL_RELEASES: '14',
ORG: 'netresearch',
});
fitFrame(tpl2, 1200, 1500);
});
tpl3.addEventListener('load', () => {
paint(tpl3, {
LABEL: 'HEADS-UP / BREAKING CHANGE',
TITLE: 'matrix-skill v2.0.0 — Python version floor raised',
SUBTITLE: 'Drops Python 3.8 (EOL). Minimum is now 3.10. Existing v1.x line continues to receive security patches until 2026-12-31.',
LEFT_LABEL: 'Before — v1.x',
LEFT_HEADING: 'Python 3.8+ supported',
LEFT_1: 'Python 3.8 / 3.9 / 3.10 / 3.11 supported',
LEFT_2: 'Type hints written for 3.8 compatibility',
LEFT_3: '<code>match</code> statements avoided in source',
LEFT_4: 'Security patches only on the v1.x branch from now on',
RIGHT_LABEL: 'After — v2.0',
RIGHT_HEADING: 'Python 3.10+ required',
RIGHT_1: 'Python 3.10 / 3.11 / 3.12 supported',
RIGHT_2: 'Modern type syntax (<code>X | Y</code>, no <code>Optional[]</code>)',
RIGHT_3: '<code>match</code> statements throughout the parser',
RIGHT_4: 'Install fails fast on 3.9 with a helpful error',
CONTEXT: 'Tracking: NRS-4421 · Migration window: 8 weeks',
ORG: 'netresearch',
});
fitFrame(tpl3, 1200, 900);
});
window.addEventListener('resize', () => {
fitFrame(tpl1, 1200, 630);
fitFrame(tpl2, 1200, 1500);
fitFrame(tpl3, 1200, 900);
});
}
if (document.readyState !== 'loading') setup();
else document.addEventListener('DOMContentLoaded', setup);
</script>
</body>
</html>
Iconography & emoji
Emoji are powerful as prefix glyphs and dangerous as decoration.
The rule: one glyph at the front of the title, optionally one inline glyph per bullet to mark category. Never trailing decoration. Never celebrations. The glyph carries meaning — if it doesn't, drop it.
Approved prefix glyphs
| Glyph | Meaning | Use case |
|---|---|---|
| 🤖 | bot | agent-authored announcement |
| 📦 | release | new feature version |
| 🔧 | tooling | infra, build, dev experience |
| 🛡 | security | CVE, hardening, lockdown |
| ⚠️ | heads-up | breaking change, deprecation |
| 📋 | digest | weekly / multi-skill roundup |
| 🔬 | RFC | proposal, request for comment |
| 🚑 | hotfix | urgent patch |
| 🔥 | postmortem | incident summary |
| ✨ | new capability | use sparingly; never on every release |
Banned
| Glyph | Why |
|---|---|
| 🚀 | "It's a release, not a launchpad." Cheapens the message. |
| 🎉 | "Nobody is celebrating." Sounds like marketing. |
| 💯 | meaningless |
| 🔥 (as "cool") | reserved for postmortems |
| Multi-emoji ladders (🚀✨🎉, 🔥🔥🔥) | Always wrong. |
| Emoji as bullet markers (🟢 🟡 🔴) | Use a real <ul> and let the client style it. |
Inline category glyphs
Inside a bullet, a leading 1-character category glyph is OK:
• ✨ new: progressive-disclosure refactor
• 🔧 changed: checkpoint runner hardened against ~10 edge cases
• 🐛 fixed: transitive dev-deps resolutionBut two glyphs in one bullet is a smell. Pick the most important one or drop both.
Why this matters
A coding agent posting 🚀✨🎉 NEW RELEASE!!! 🎉✨🚀 reads to humans as AI slop. The same announcement with 📦 Release: matrix-skill v1.20.0 reads as professional. The information content is identical; the emoji choice is the entire signal of seriousness.
When in doubt: one glyph or none.
The HTML subset Matrix clients render
The Matrix spec defines an explicit allow-list. Element, Cinny, FluffyChat all converge on roughly the same set. Treat this as the floor — if it's not on the allow-list, assume the client strips it silently.
Allowed
Inline: <a>, <b>, <i>, <u>, <strong>, <em>, <code>, <del>, <strike>, <sub>, <sup>, <br>, <span>
Block: <p>, <div>, <blockquote>, <pre>, <hr>, <h3>–<h6>
Lists: <ul>, <ol>, <li>
Tables: <table>, <thead>, <tbody>, <tr>, <th>, <td>, <caption>
Matrix-specific:
<span data-mx-color="#…">anddata-mx-bg-colorfor color<span data-mx-spoiler="reason">for spoilers<a href="https://matrix.to/#/@user:server">@user</a>for mentions<mx-reply>— client-injected on rich replies; never write it yourself<pre><code class="language-bash">— Element renders syntax highlighting; common classes:language-bash,language-python,language-json,language-diff,language-yaml
Banned (silently stripped)
<script>,<iframe>,<form>,<style>— entire elements removedstyle="…"attributes — beyonddata-mx-*, all inline CSS is stripped<img src="https://…">from arbitrary URLs — images go in viam.imageevents withmxc://URIs<video>,<audio>— same; usem.video/m.audioeventsclass="…"— mostly stripped (Element keepslanguage-…on<code>)<h1>,<h2>— technically allowed but render as system-banner-loud in Element. Start at<h3>in practice.
Markdown vs HTML — pick one and convert
If your skill composes in Markdown internally, run it through a real converter (markdown-it, commonmark) to produce formatted_body. Do not send raw Markdown in formatted_body and hope the client renders it — most do not.
body may keep the Markdown-ish look (backticks, - bullets) — that is fine and even helpful for plaintext readers.
body is not optional
Notifications, push alerts, screen readers, search indexes, IRC/XMPP bridges, and CLI Matrix clients all read body, not formatted_body. Write it as a real readable message, not stripped HTML. Two good patterns:
- Strip-and-keep — drop tags, keep newlines, keep punctuation. Bullet glyphs (
•,-) stay in. Code stays unfenced but with backticks. - Short summary — when the HTML is dense (tables, cards), set
bodyto a one- or two-line summary plus a link.
A minimal rich event
{
"msgtype": "m.text",
"format": "org.matrix.custom.html",
"body": "Release: github-release-skill v0.2.0\nRepo: https://github.com/netresearch/github-release-skill",
"formatted_body": "<p><strong>Release:</strong> <code>github-release-skill</code> v0.2.0<br/>Repo: <a href=\"https://github.com/netresearch/github-release-skill\">netresearch/github-release-skill</a></p>"
}Two fields decide rendering: body (plaintext fallback — required) and formatted_body (HTML, only when format is set).
When to render an HTML card to PNG
formatted_body is fine for prose, lists and short tables. It is bad at:
- Comparisons (matrix client X vs Y, before/after, plan tiers)
- Dashboards (test counts, coverage deltas, dependency health)
- Timelines (release calendar, incident timeline)
- Anything with column alignment beyond ~3 columns
- Anything that needs color-coded status pills, badges, or icons
- Hero / announce cards for major releases where visual identity helps the message survive scrolling
For these: design an HTML card → render to PNG headlessly → upload to the homeserver → post as `m.image`. The plaintext body becomes the searchable fallback.
The recipe
--window-size must match the chosen template's design dimensions and the info.w/info.h you send in the m.image event below — otherwise the screenshot is cropped or letterboxed and the metadata lies. The shipped templates are:
release-card.html→1200,630weekly-digest.html→1200,1500comparison.html→1200,900
# 1. Render — substitute the template's WIDTH,HEIGHT for the --window-size
W=1200; H=630 # release-card.html dimensions
chromium --headless=new \
--disable-gpu \
--hide-scrollbars \
--window-size=$W,$H \
--screenshot=card.png \
--default-background-color=00000000 \
"file://$(pwd)/card.html"
# 2. Upload to homeserver → mxc:// URI
MXC=$(curl -s -X POST \
-H "Authorization: Bearer $MATRIX_TOKEN" \
-H "Content-Type: image/png" \
--data-binary @card.png \
"$HOMESERVER/_matrix/media/v3/upload?filename=card.png" \
| jq -r .content_uri)
# 3. Send m.image (file size via wc -c so it works on both Linux and macOS;
# `stat -c%s` is GNU-only, `stat -f%z` is BSD-only — wc -c < FILE is portable)
SIZE=$(wc -c < card.png | tr -d ' ')
curl -s -X PUT \
-H "Authorization: Bearer $MATRIX_TOKEN" \
-H "Content-Type: application/json" \
-d "{
\"msgtype\": \"m.image\",
\"body\": \"github-release-skill v0.2.0 — release card\",
\"url\": \"$MXC\",
\"info\": {
\"mimetype\": \"image/png\",
\"w\": $W,
\"h\": $H,
\"size\": $SIZE
}
}" \
"$HOMESERVER/_matrix/client/v3/rooms/$ROOM/send/m.room.message/$(uuidgen)"
# 4. Follow with the text/links nobody can copy from an image.
# ${CLAUDE_SKILL_DIR} is substituted by Claude Code to the active skill's
# directory; the matrix-communication scripts live one directory up.
# Do NOT quote it — it is a literal substitution, not a shell variable.
uv run ${CLAUDE_SKILL_DIR}/../matrix-communication/scripts/matrix-send-e2ee.py "$ROOM" \
'**Install:** `/install-plugin https://github.com/netresearch/github-release-skill`'Note:m.noticeis a text msgtype — there is no notice-flavouredm.image. For text-only announcements that should be unreplyable by other bots, pass--noticetomatrix-send-e2ee.py. For an image announcement that should still be skipped by auto-replying bots, send the card asm.image(the recipe above) and then a follow-upm.textviamatrix-send-e2ee.py … --noticecarrying the install command and links — bots that respectm.noticewill then ignore the whole announcement, since they ignore the text and the image is wordless.
Why "design HTML, render to PNG" beats "have the LLM make an image"
- HTML/CSS gives pixel-precise control over typography, spacing, color
- The same template renders deterministically every week — diffs are obvious, regressions catchable
- Coding agents are excellent at writing HTML and bad at generating rasters; lean into the strength
- Templates are reviewable code (
templates/release-card.html), not disposable prompts
Three templates
| Template | Use for | Size |
|---|---|---|
templates/release-card.html | single-skill release announcements | 1200×630 |
templates/weekly-digest.html | multi-skill weekly roundups | 1200×1500 |
templates/comparison.html | before/after, vs-tables, migration deltas | 1200×900 |
Each is a self-contained HTML file with {{PLACEHOLDER}} substitutions the calling skill replaces before rendering. Open gallery.html for a live preview.
Always pair the image with text
An m.image event alone is hostile to:
- screen-reader users
- mobile users on metered connections
- anyone scrolling search results
- anyone trying to copy the install command
Either set the image's body to the full plaintext fallback the announcement would have been, or send the card and immediately follow with a short m.text containing the links and commands.
{
"msgtype": "m.image",
"body": "Release: github-release-skill v0.2.0\n\nWhat's new:\n- detects ecosystem & bumps versions\n- signed annotated tags only\n- rewrites release notes into narrative\n\nInstall: /install-plugin https://github.com/netresearch/github-release-skill\nRepo: https://github.com/netresearch/github-release-skill",
"url": "mxc://example.com/abc123…",
"info": { "w": 1200, "h": 630, "mimetype": "image/png", "size": 184223 }
}Announcement structure
Every announcement, regardless of topic, has the same skeleton. Readers learn the rhythm and skim faster.
[icon] [Type tag]: [Subject] [— optional version]
[one-sentence why-this-matters]
[Section heading 1]
- bullet
- bullet
[Section heading 2]
- bullet
[Footer: links / tracking ticket / install command]The icon and tag work like a subject-line prefix in email ([SECURITY], [RELEASE]). They let humans and grep-style filters skim a busy room.
Type tags
Pick one. Do not stack.
| Tag | Meaning | Title example |
|---|---|---|
New skill | first public release of a skill | New skill: github-release-skill v0.2.0 |
Release | new feature version of an existing skill | Release: jira-skill v3.12.0 |
Patch | bugfix-only bump | Patch: docker-development-skill v1.7.0 |
Digest | weekly / multi-skill roundup | Digest: skill ecosystem — week of 2026-04-22 |
Heads-up | breaking change, deprecation, migration | Heads-up: matrix-skill v2 drops Python 3.8 |
Postmortem | incident summary | Postmortem: CI cache wipe 2026-04-25 |
RFC | proposal looking for feedback | RFC: unified checkpoint schema |
Section headings
Use at most three sections per message. Common patterns:
- What it does / What it prevents / Install — for new skill releases
- New / Changed / Fixed — for version bumps
- Highlights / This week's releases / Open questions — for digests
- Symptom / Cause / Fix — for postmortems
- Why / How / Try it — for proposals
Render headings as <strong>Heading:</strong> on its own line, or <h3> if the message is long enough to warrant TOC-style skimming. Avoid <h2>/<h1> (too loud).
Sentence economy
- First sentence states the change. "X now does Y." Not "We are excited to announce that …".
- Second sentence states the consequence. "Existing tags continue to work; new tags require
-s." - Skip the third sentence. If you need it, it is a bullet.
When to use which element
| Element | Use it for | Avoid for |
|---|---|---|
<strong> | the one word in a sentence the reader must not miss | every other word |
<code> | commands, paths, identifiers, version strings, env vars, JSON keys | English nouns |
<pre><code> | multi-line commands, JSON examples, diff snippets | one-liners |
<ul> | 2–7 unordered items | a list of one — write a sentence |
<ol> | numbered steps where order matters | bullets that just happen to have numbers |
<blockquote> | quoting a user, an error, a previous decision | indenting for visual variety |
<table> | tabular data ≥2 cols × ≥3 rows | two-column term/definition pairs |
<hr> | separating an unrelated postscript | between every section (use spacing) |
<a> | every URL — never paste raw URLs in formatted_body | "click here" — use the destination as text |
<br> | a forced line break inside a paragraph | between paragraphs (use <p>) |
Links: write what they are
✗ More info: <a href="…/issues/4365">here</a>
✓ Tracking: <a href="…/issues/4365">NRS-4365</a>
✓ Repo: <a href="https://github.com/netresearch/github-release-skill">github.com/netresearch/github-release-skill</a>The link text should be the destination's identity (ticket, repo, doc title), not a verb.
Link every entity, not just "the" link
Readers click the thing they are looking at — every linkable entity in the message body is a link, including incidental mentions:
- Issue keys — every occurrence, even mid-sentence context (
blockers from <a href="…/SRVC-99">SRVC-99</a> cleared), not only the headline ticket. - Version numbers — link to their release page, keeping the version in
<code>inside the link:traefik <a href="…/releases/tag/v3.7.1"><code>3.7.1</code></a>→<a href="…/releases/tag/v3.7.4"><code>3.7.4</code></a>. A version without its release notes makes the reader search for them. - MRs / PRs — use the platform's reference notation as text: GitLab
<a href="…">project/path!30</a>, GitHub<a href="…">org/repo#42</a>. Never bare "MR !30" — it is ambiguous across projects and unclickable when copied. - Pipelines, commits, tags, branches — same rule: identity as text, URL behind it.
Status updates: one item per line
Multi-item progress posts (maintenance logs, digest-style updates) get one line per ticket/work item, separated by blank lines, each line starting with the linked issue key. Readers scan for their item; interleaved prose hides it.
Length budget
| Metric | Target | Hard limit |
|---|---|---|
| Title line | 6–12 words | 16 words |
| Lede sentence | 12–20 words | 30 words |
| Bullets per section | 3–5 | 7 |
| Sections per message | 2–3 | 4 |
Total formatted_body length | ≤1500 chars | 3000 chars |
| Code blocks per message | ≤1 | 2 |
If you blow past the hard limit, you have a digest, not an announcement. Split it: short headline message in the room, full content in a thread or a linked doc.
m.text vs m.notice
msgtype | Use for | Why |
|---|---|---|
m.notice | Unattended automation: release announcements, CI summaries, scheduled digests, alert pings | Clients render it visually distinct (usually muted) and bots are forbidden from auto-replying to it — prevents bot-on-bot loops |
m.text | Agent posting on behalf of a human who reviewed it (or a real human typing) | Default; replies welcome |
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>Comparison card</title>
<style>
:root {
--ink: #0e1116;
--ink-soft: #44505d;
--ink-faint: #7d8896;
--paper: #fafaf7;
--line: #e3e1d8;
--bad: #8a3a2a;
--good: #2f6f3e;
--bad-bg: #f8eee9;
--good-bg: #ecf2ec;
}
* { box-sizing: border-box; }
html, body { margin: 0; padding: 0; }
body {
width: 1200px; height: 900px;
background: var(--paper);
color: var(--ink);
font-family: ui-sans-serif, "Helvetica Neue", Helvetica, Arial, sans-serif;
-webkit-font-smoothing: antialiased;
}
.frame {
margin: 32px;
background: #fff;
border: 1px solid var(--line);
border-radius: 6px;
padding: 48px 56px 56px;
height: calc(900px - 64px);
display: flex; flex-direction: column;
}
.head {
margin-bottom: 28px;
}
.label {
font-family: ui-monospace, "JetBrains Mono", monospace;
font-size: 13px; letter-spacing: 0.1em; text-transform: uppercase;
color: var(--ink-faint); margin-bottom: 10px;
}
h1 {
font-size: 44px; letter-spacing: -0.02em; margin: 0 0 10px;
font-weight: 600; text-wrap: balance;
}
.sub {
font-size: 18px; color: var(--ink-soft); margin: 0;
}
.pair {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 24px;
flex: 1;
}
.panel {
border: 1px solid var(--line);
border-radius: 6px;
padding: 28px 32px;
display: flex; flex-direction: column;
}
.panel.bad { background: var(--bad-bg); border-color: #e8c8be; }
.panel.good { background: var(--good-bg); border-color: #c8d8c8; }
.panel h2 {
margin: 0 0 6px;
font-size: 13px; letter-spacing: 0.1em; text-transform: uppercase;
font-weight: 600;
}
.panel.bad h2 { color: var(--bad); }
.panel.good h2 { color: var(--good); }
.panel .heading {
font-size: 24px; font-weight: 600; letter-spacing: -0.01em;
margin: 0 0 18px; color: var(--ink);
}
.panel ul {
margin: 0; padding: 0; list-style: none;
flex: 1;
}
.panel li {
font-size: 16px; line-height: 1.45;
padding: 6px 0 6px 22px; position: relative;
color: var(--ink);
}
.panel.bad li::before {
content: "✗"; position: absolute; left: 0; top: 6px;
color: var(--bad); font-weight: 600;
}
.panel.good li::before {
content: "✓"; position: absolute; left: 0; top: 6px;
color: var(--good); font-weight: 600;
}
.panel li code {
font-family: ui-monospace, "JetBrains Mono", monospace;
font-size: 0.92em;
background: rgba(0,0,0,0.04);
padding: 1px 5px; border-radius: 3px;
}
.footer {
margin-top: 24px; padding-top: 16px;
border-top: 1px solid var(--line);
display: flex; justify-content: space-between;
font-family: ui-monospace, "JetBrains Mono", monospace;
font-size: 13px; color: var(--ink-faint);
}
</style>
</head>
<body>
<div class="frame">
<div class="head">
<div class="label">{{LABEL}}</div>
<h1>{{TITLE}}</h1>
<p class="sub">{{SUBTITLE}}</p>
</div>
<div class="pair">
<div class="panel bad">
<h2>{{LEFT_LABEL}}</h2>
<div class="heading">{{LEFT_HEADING}}</div>
<ul>
<li>{{LEFT_1}}</li>
<li>{{LEFT_2}}</li>
<li>{{LEFT_3}}</li>
<li>{{LEFT_4}}</li>
</ul>
</div>
<div class="panel good">
<h2>{{RIGHT_LABEL}}</h2>
<div class="heading">{{RIGHT_HEADING}}</div>
<ul>
<li>{{RIGHT_1}}</li>
<li>{{RIGHT_2}}</li>
<li>{{RIGHT_3}}</li>
<li>{{RIGHT_4}}</li>
</ul>
</div>
</div>
<div class="footer">
<span>{{CONTEXT}}</span>
<span><b style="color:var(--ink)">{{ORG}}</b> · matrix announcement</span>
</div>
</div>
</body>
</html>
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>Release card</title>
<style>
:root {
--ink: #0e1116;
--ink-soft: #44505d;
--ink-faint: #7d8896;
--paper: #fafaf7;
--line: #e3e1d8;
--accent: #b85c1d;
--good: #2f6f3e;
--warn: #8a6a16;
}
* { box-sizing: border-box; }
html, body { margin: 0; padding: 0; }
body {
width: 1200px; height: 630px;
background: var(--paper);
color: var(--ink);
font-family: ui-sans-serif, "Helvetica Neue", Helvetica, Arial, sans-serif;
font-feature-settings: "ss01", "cv11";
-webkit-font-smoothing: antialiased;
position: relative;
overflow: hidden;
}
.frame {
position: absolute; inset: 32px;
border: 1px solid var(--line);
border-radius: 6px;
background: #fff;
padding: 56px 64px;
display: flex; flex-direction: column;
}
.meta {
display: flex; align-items: center; gap: 14px;
font-family: ui-monospace, "JetBrains Mono", "SF Mono", Menlo, monospace;
font-size: 14px; letter-spacing: 0.04em;
color: var(--ink-faint); text-transform: uppercase;
}
.meta .dot { width: 8px; height: 8px; border-radius: 50%; background: var(--accent); }
.meta .sep { color: var(--line); }
.tag {
display: inline-block;
padding: 4px 10px;
border: 1px solid var(--line);
border-radius: 999px;
font-size: 12px; letter-spacing: 0.08em;
color: var(--ink-soft);
background: var(--paper);
}
h1 {
font-size: 64px; line-height: 1.05; letter-spacing: -0.02em;
margin: 28px 0 16px;
font-weight: 600;
text-wrap: balance;
}
h1 code {
font-family: ui-monospace, "JetBrains Mono", "SF Mono", Menlo, monospace;
font-size: 0.78em; background: var(--paper);
padding: 2px 10px; border-radius: 6px; border: 1px solid var(--line);
vertical-align: 0.08em;
}
.lede {
font-size: 22px; line-height: 1.4; color: var(--ink-soft);
margin: 0 0 36px; max-width: 940px; text-wrap: pretty;
}
.grid {
display: grid; grid-template-columns: 1fr 1fr; gap: 28px 56px;
flex: 1;
}
.col h3 {
font-size: 13px; letter-spacing: 0.1em; text-transform: uppercase;
color: var(--ink-faint); margin: 0 0 10px; font-weight: 600;
}
.col ul { margin: 0; padding: 0; list-style: none; }
.col li {
font-size: 18px; line-height: 1.45;
padding: 6px 0 6px 18px; position: relative;
color: var(--ink);
}
.col li::before {
content: ""; position: absolute; left: 0; top: 16px;
width: 8px; height: 1px; background: var(--accent);
}
.col li code {
font-family: ui-monospace, "JetBrains Mono", "SF Mono", Menlo, monospace;
font-size: 0.92em; background: var(--paper);
padding: 1px 6px; border-radius: 4px; border: 1px solid var(--line);
}
.footer {
display: flex; justify-content: space-between; align-items: flex-end;
margin-top: 32px; padding-top: 20px; border-top: 1px solid var(--line);
}
.install {
font-family: ui-monospace, "JetBrains Mono", "SF Mono", Menlo, monospace;
font-size: 16px; color: var(--ink);
background: var(--paper); border: 1px solid var(--line);
padding: 10px 16px; border-radius: 6px;
}
.install .prompt { color: var(--ink-faint); user-select: none; }
.brand {
font-family: ui-monospace, "JetBrains Mono", "SF Mono", Menlo, monospace;
font-size: 13px; color: var(--ink-faint);
text-align: right;
}
.brand b { color: var(--ink); font-weight: 600; }
</style>
</head>
<body>
<div class="frame">
<div class="meta">
<span class="dot"></span>
<span>{{TYPE_TAG}}</span>
<span class="sep">·</span>
<span>{{DATE}}</span>
<span class="sep">·</span>
<span class="tag">{{ECOSYSTEM}}</span>
</div>
<h1>{{TITLE}} <code>v{{VERSION}}</code></h1>
<p class="lede">{{LEDE}}</p>
<div class="grid">
<div class="col">
<h3>What it does</h3>
<ul>
<li>{{DOES_1}}</li>
<li>{{DOES_2}}</li>
<li>{{DOES_3}}</li>
</ul>
</div>
<div class="col">
<h3>What it prevents</h3>
<ul>
<li>{{PREVENTS_1}}</li>
<li>{{PREVENTS_2}}</li>
<li>{{PREVENTS_3}}</li>
</ul>
</div>
</div>
<div class="footer">
<div class="install">
<span class="prompt">$ </span>/install-plugin {{REPO_URL}}
</div>
<div class="brand">
<b>{{ORG}}</b> · matrix announcement
</div>
</div>
</div>
</body>
</html>
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>Weekly digest card</title>
<style>
:root {
--ink: #0e1116;
--ink-soft: #44505d;
--ink-faint: #7d8896;
--paper: #fafaf7;
--line: #e3e1d8;
--accent: #b85c1d;
--new: #2f6f3e;
--feat: #1d5a8a;
--patch: #7d8896;
}
* { box-sizing: border-box; }
html, body { margin: 0; padding: 0; }
body {
width: 1200px; height: 1500px;
background: var(--paper);
color: var(--ink);
font-family: ui-sans-serif, "Helvetica Neue", Helvetica, Arial, sans-serif;
-webkit-font-smoothing: antialiased;
}
.frame {
margin: 32px;
background: #fff;
border: 1px solid var(--line);
border-radius: 6px;
padding: 56px 64px 64px;
}
.head {
display: flex; justify-content: space-between; align-items: baseline;
border-bottom: 1px solid var(--line);
padding-bottom: 24px; margin-bottom: 36px;
}
.head h1 {
font-size: 48px; letter-spacing: -0.02em; margin: 0;
font-weight: 600;
}
.head .range {
font-family: ui-monospace, "JetBrains Mono", monospace;
font-size: 14px; color: var(--ink-faint);
letter-spacing: 0.04em; text-transform: uppercase;
}
.lede {
font-size: 20px; line-height: 1.45;
color: var(--ink-soft); margin: 0 0 40px; max-width: 880px;
}
section { margin-bottom: 36px; }
section h2 {
font-size: 13px; letter-spacing: 0.12em; text-transform: uppercase;
color: var(--ink-faint); margin: 0 0 16px; font-weight: 600;
padding-bottom: 8px; border-bottom: 1px dashed var(--line);
}
.row {
display: grid;
grid-template-columns: 110px 240px 1fr 90px;
gap: 24px;
padding: 12px 0;
align-items: baseline;
border-bottom: 1px solid var(--line);
font-size: 17px;
}
.row:last-child { border-bottom: 0; }
.pill {
font-family: ui-monospace, "JetBrains Mono", monospace;
font-size: 11px; letter-spacing: 0.08em;
text-transform: uppercase;
padding: 3px 8px; border-radius: 4px;
border: 1px solid currentColor;
display: inline-block;
width: fit-content;
}
.pill.new { color: var(--new); }
.pill.feat { color: var(--feat); }
.pill.patch { color: var(--patch); }
.name {
font-family: ui-monospace, "JetBrains Mono", monospace;
font-size: 16px; color: var(--ink);
}
.what { color: var(--ink-soft); line-height: 1.4; }
.ver {
font-family: ui-monospace, "JetBrains Mono", monospace;
font-size: 14px; color: var(--ink-faint); text-align: right;
}
.footer {
margin-top: 40px; padding-top: 24px;
border-top: 1px solid var(--line);
display: flex; justify-content: space-between;
font-family: ui-monospace, "JetBrains Mono", monospace;
font-size: 13px; color: var(--ink-faint);
}
</style>
</head>
<body>
<div class="frame">
<div class="head">
<h1>Skill ecosystem update</h1>
<div class="range">Week of {{WEEK_START}} → {{WEEK_END}}</div>
</div>
<p class="lede">{{LEDE}}</p>
<section>
<h2>New skills published</h2>
<div class="row">
<span class="pill new">new</span>
<span class="name">{{NEW_NAME_1}}</span>
<span class="what">{{NEW_DESC_1}}</span>
<span class="ver">v{{NEW_VER_1}}</span>
</div>
<div class="row">
<span class="pill new">new</span>
<span class="name">{{NEW_NAME_2}}</span>
<span class="what">{{NEW_DESC_2}}</span>
<span class="ver">v{{NEW_VER_2}}</span>
</div>
</section>
<section>
<h2>Feature releases</h2>
<div class="row">
<span class="pill feat">feat</span>
<span class="name">{{FEAT_NAME_1}}</span>
<span class="what">{{FEAT_DESC_1}}</span>
<span class="ver">v{{FEAT_VER_1}}</span>
</div>
<div class="row">
<span class="pill feat">feat</span>
<span class="name">{{FEAT_NAME_2}}</span>
<span class="what">{{FEAT_DESC_2}}</span>
<span class="ver">v{{FEAT_VER_2}}</span>
</div>
<div class="row">
<span class="pill feat">feat</span>
<span class="name">{{FEAT_NAME_3}}</span>
<span class="what">{{FEAT_DESC_3}}</span>
<span class="ver">v{{FEAT_VER_3}}</span>
</div>
</section>
<section>
<h2>Patches</h2>
<div class="row">
<span class="pill patch">patch</span>
<span class="name">{{PATCH_NAME_1}}</span>
<span class="what">{{PATCH_DESC_1}}</span>
<span class="ver">v{{PATCH_VER_1}}</span>
</div>
<div class="row">
<span class="pill patch">patch</span>
<span class="name">{{PATCH_NAME_2}}</span>
<span class="what">{{PATCH_DESC_2}}</span>
<span class="ver">v{{PATCH_VER_2}}</span>
</div>
<div class="row">
<span class="pill patch">patch</span>
<span class="name">{{PATCH_NAME_3}}</span>
<span class="what">{{PATCH_DESC_3}}</span>
<span class="ver">v{{PATCH_VER_3}}</span>
</div>
</section>
<div class="footer">
<span>{{TOTAL_REPOS}} repos · {{TOTAL_RELEASES}} releases</span>
<span><b style="color:var(--ink)">{{ORG}}</b> · matrix digest</span>
</div>
</div>
</body>
</html>
Text-only formatted_body templates
Drop-in skeletons. Substitute {placeholders}. Each is paired with a body plaintext fallback.
Single-skill release
<p>📦 <strong>Release:</strong> <code>{skill}</code> v{version}</p>
<p>{one-sentence summary of what this version is about}.</p>
<p><strong>What's new:</strong></p>
<ul>
<li>{change 1}</li>
<li>{change 2}</li>
<li>{change 3}</li>
</ul>
<p><strong>Install:</strong> <code>/install-plugin {repo-url}</code><br/>
<strong>Repo:</strong> <a href="{repo-url}">{org}/{repo}</a><br/>
<strong>Tracking:</strong> <a href="{ticket-url}">{ticket-id}</a></p>Plaintext body:
Release: {skill} v{version}
{one-sentence summary}.
What's new:
- {change 1}
- {change 2}
- {change 3}
Install: /install-plugin {repo-url}
Repo: {repo-url}
Tracking: {ticket-id} {ticket-url}New skill announcement
<p>🤖 <strong>New skill:</strong> <code>{skill}</code> v{version}</p>
<p>{problem this skill solves, in one sentence}.</p>
<p><strong>What it does:</strong></p>
<ul>
<li>{capability 1}</li>
<li>{capability 2}</li>
</ul>
<p><strong>What it prevents:</strong></p>
<ul>
<li><code>{blocked-command}</code> — {why}</li>
</ul>
<p><strong>Install:</strong> <code>/install-plugin {repo-url}</code></p>Weekly digest (text version — but consider a card image)
<p>📋 <strong>Skill ecosystem update</strong> — releases since {date-range}</p>
<p><strong>New skills this week:</strong></p>
<ul>
<li><code>{skill}</code> v{version} — {one-line description}</li>
</ul>
<p><strong>Releases ({n} repos):</strong></p>
<ul>
<li><code>{skill}</code> v{version} — {what changed}</li>
</ul>
<p><strong>Patches:</strong></p>
<ul>
<li><code>{skill}</code> v{version}</li>
</ul>For more than ~6 lines of releases, render templates/weekly-digest.html to PNG and post that instead.
Heads-up / breaking change
<p>⚠️ <strong>Heads-up:</strong> <code>{skill}</code> v{version} — {what breaks}</p>
<p>{one-sentence why}.</p>
<p><strong>What changes:</strong> {concrete behavior diff}.</p>
<p><strong>Migration:</strong> {steps the reader must take}.</p>
<p><strong>Timeline:</strong> {when old behavior goes away}.</p>
<p><strong>Help:</strong> <a href="{thread-or-issue}">{ticket-id}</a></p>Postmortem
<p>🔥 <strong>Postmortem:</strong> {what failed} on {date}</p>
<blockquote>
<p><strong>Impact:</strong> {who was affected, for how long}.</p>
</blockquote>
<p><strong>Symptom:</strong> {observed behavior}.</p>
<p><strong>Cause:</strong> {root cause in one sentence}.</p>
<p><strong>Fix:</strong> {what was done}.</p>
<p><strong>Follow-up:</strong> <a href="{ticket}">{ticket-id}</a></p>RFC
<p>🔬 <strong>RFC:</strong> {proposal title}</p>
<p>{one-sentence problem statement}.</p>
<p><strong>Why:</strong> {motivation}.</p>
<p><strong>How:</strong> {sketch of the approach}.</p>
<p><strong>Try it:</strong> <code>{command or branch}</code></p>
<p><strong>Feedback by:</strong> {date}, in <a href="{thread}">this thread</a>.</p>Patch (bugfix-only)
<p>🚑 <strong>Patch:</strong> <code>{skill}</code> v{version}</p>
<p>{one-sentence bug summary}.</p>
<p><strong>Fixed:</strong> {what was wrong} → {what is now correct}.<br/>
<strong>Affected:</strong> {who needs to upgrade}.</p>Threads, mentions, edits, redactions
Threading
A long announcement should not become a long message. Post the headline + summary as the parent; put extended notes, screenshots, and Q&A follow-ups in a thread.
A spec-compliant threaded reply has three relation fields — rel_type, the thread root event_id, an is_falling_back flag, and an m.in_reply_to pointing at the parent (root or last reply) so non-thread-aware clients still render the message as a normal reply:
"m.relates_to": {
"rel_type": "m.thread",
"event_id": "$thread_root_event_id",
"is_falling_back": true,
"m.in_reply_to": {
"event_id": "$parent_event_id"
}
}event_id is always the thread root. m.in_reply_to.event_id is the previous message in the thread (or the root for the first reply). is_falling_back: true tells thread-aware clients to suppress the reply UI — the m.in_reply_to is purely a fallback for older clients.
When in doubt, thread it. Rooms scroll fast.
Mentions
Tag people only when they actually need to see it. Use proper Matrix mentions, not plaintext @name:
cc <a href="https://matrix.to/#/@sebastian:example.com">@sebastian</a>Include the mentions block (MSC3952, now spec) so notifications fire correctly:
"m.mentions": {
"user_ids": ["@sebastian:example.com"]
}For room-wide pings (@room), set "room": true in m.mentions. Reserve them for outages — every misuse trains people to mute the room.
Edits
If you need to fix a typo within ~5 minutes, edit:
{
"msgtype": "m.text",
"body": "* corrected text",
"format": "org.matrix.custom.html",
"formatted_body": "* <p>corrected text</p>",
"m.new_content": {
"msgtype": "m.text",
"body": "corrected text",
"format": "org.matrix.custom.html",
"formatted_body": "<p>corrected text</p>"
},
"m.relates_to": {
"rel_type": "m.replace",
"event_id": "$original_event_id"
}
}The outer body / formatted_body is the fallback text shown by clients that don't render edits — prefix it with * so the asterisk indicates "this is an edit". m.new_content is the replacement content; if you set format: org.matrix.custom.html, formatted_body must be valid HTML.
If the message is already an hour old, post a follow-up reply instead — edits to old messages are easy to miss and notification-silent.
Redactions
Redact only when the content is wrong-and-harmful:
- Leaked secret (token, password, API key)
- Mistargeted ping that woke up the wrong on-call rotation
- Personal data published to a public room
Never redact "to clean up" — the audit trail is more valuable than tidiness, and redactions are themselves visible events that draw attention.
{
"type": "m.room.redaction",
"redacts": "$event_id_to_redact",
"content": {
"reason": "leaked secret"
}
}Always include a reason. Redactions without context look like coverups.