What ships is already written down somewhere: a CHANGELOG.md, a folder
of fragments, the pull requests merged, the releases published. Tayori
reads it, keeps it in an inbox, and makes entries of it, plain or
written by the model, as often as you like. The readers hear of them as
often as they like.
sources ──► inbox ──► digest ──► entry ──► page, panel, feeds
CI, GitHub, the changes plain or a draft, or emails, each one
your code waiting by the AI published or by periodEach step is yours to set, and each can be done by hand instead:
- The inbox (
Tayori.Inbox) takes the changes: from the sources of the:inboxconfig when the app starts, from CI, from GitHub's webhook, from your code, and from an agent over MCP. - A digest (
Tayori.Digest) makes an entry of the changes waiting: when you ask, each time changes come, or on a schedule. - The entry is a draft for you to read, unless you say to publish it.
- The emails go for each entry, or once a week or a month.
Nothing of it runs until you set it up. Without the options below, the inbox is empty and the news is written by hand, as before.
The inbox
A change is what shipped, in a line or a paragraph: a title, a body in
Markdown, and what it is (Tayori.Change). It has a source ("changelog",
"github", "api"...) and a ref, its name in that source; the same
source and ref is the same change, so reading a source again adds only
what is new.
A change is for the public, or for the team (audience: "internal").
The team's never go into an entry: they are in the inbox so that you see
everything that shipped, and decide.
The app's sources
{Tayori,
repo: MyApp.Repo,
pubsub: MyApp.PubSub,
inbox: [
sources: [
{Tayori.Sources.Changelog, path: {:my_app, "priv/CHANGELOG.md"}},
{Tayori.Sources.Fragments, path: {:my_app, "priv/news"}}
],
kinds: %{"added" => "new", "changed" => "improved", "fixed" => "fixed"}
]}The sources are read when the app starts, which is when a deploy brings
new code (ingest_on_start: false to read them only when asked). A
{otp_app, path} is in the app's priv, so it is in the release.
Tayori.Sources.Changelog reads a Keep a
Changelog file: each item of each release
is a change, with the release's version and date and the section's kind
(added, fixed...). [Unreleased] is left until it is released
(sections: :all to take it). An item can say more about itself in a
comment at its end:
### Added
- Exports in two clicks: zip a project with its files. <!-- highlight; category: new -->
- The job queue runs on the new cluster. <!-- audience: internal -->Tayori.Sources.Fragments reads a folder of Markdown files, one change
each, as Changesets and
towncrier write them. The
frontmatter says what the change is; the rest is its body, and its first
heading or sentence its title when the frontmatter has none:
---
title: Exports in two clicks
kind: added
highlight: true
segments: [plan:pro]
---
Zip a whole project, with its files, and get a link by email.towncrier's 1234.feature.md has the kind feature. A key Tayori
does not know is kept in the change's meta. A README.md in the
folder is not a change.
A source of your own is a module of Tayori.Source, with changes/1.
Kinds and categories
A source names kinds (added, fix, feature); your entries have
categories. :kinds maps one to the other. A change with no category
of its own takes its kind's, or the category whose key is its kind, or
none.
:audience is the audience of a change that does not say: "public",
or "internal" for an app whose sources say everything, where the
admin picks what the public hears.
In the admin
The inbox has its page in the admin, with a count of the new changes in the bar. The changes are there by state (new, drafted, put aside), by source and audience. Pick some, and Draft an entry makes a digest of them and opens it in the editor; Draft from all new takes every new public change. A change can be edited before (its title, category, audience, whether to lead with it), put aside, or brought back.
Settings → Automation shows the sources, the digest's schedule, and makes the token for CI and the secret for GitHub. The editor shows the changes an entry was made from.
From CI
Mount the API for machines
CI and GitHub have no session of your app. Mount the JSON API a second
time, behind a pipeline with no session and no CSRF protection, as the
:embed feature does:
pipeline :machines do
plug :accepts, ["json"]
end
scope "/api/machines" do
pipe_through :machines
tayori_api "/news", resolver: MyAppWeb.TayoriResolver, as: :tayori_machines
endTwo of its routes are for machines, and read no session:
POST /api/machines/news/changes | changes, with the ingest token |
POST /api/machines/news/hooks/github | GitHub's webhook, signed with its secret |
The mount with your session has them too, but there a POST needs the
CSRF token of the session, as every POST behind
protect_from_forgery does: CI has none. They do not need the :embed
feature, and answer no CORS: a browser has no business with them.
The ingest token
The admin makes the token in Settings, and sees it once. Tayori keeps
only its SHA-256. Making another revokes the one before. Keep it in your
CI's secrets, as TAYORI_INGEST_TOKEN. While no token was made, the
route answers 404.
mix tayori.ingest
In CI, mix tayori.ingest reads your changelog and your fragments, and
sends their changes to the app. It does not start the app, and needs no
database. It needs the :req dependency.
mix tayori.ingest \
--url https://myapp.com/api/machines/news \
--changelog CHANGELOG.md \
--fragments priv/news
The token is --token, or the TAYORI_INGEST_TOKEN environment
variable. --source names the source of every change (changelog and
fragments by default, as the sources of the :inbox config name
theirs, so a change read by both is one). It sends 500 changes a request
at most, prints how many were new, and fails when the app refuses a
request or a change.
A step of GitHub Actions, after the deploy:
- name: Send the changes to Tayori
run: mix tayori.ingest --url https://myapp.com/api/machines/news --changelog CHANGELOG.md
env:
TAYORI_INGEST_TOKEN: ${{ secrets.TAYORI_INGEST_TOKEN }}Any other client
The route takes JSON, with the token in the authorization header:
curl -X POST https://myapp.com/api/machines/news/changes \
-H "authorization: Bearer $TAYORI_INGEST_TOKEN" \
-H "content-type: application/json" \
-d '{"changes": [{"ref": "pr-1234", "title": "Exports in two clicks", "kind": "added"}]}'
A change is the attributes of Tayori.Change: ref and title are
needed; body (Markdown), kind, category, audience, segments,
highlight, version, url, author, occurred_at and meta are
not. Its source is "api" unless it says. {"change": {...}} sends
one.
{"added": 1, "existing": 0, "errors": [{"index": 2, "errors": {"title": ["can't be blank"]}}]}| Status | |
|---|---|
| 201 | at least one change was new |
| 200 | none was new |
| 422 | none could be added |
| 400 | the body is not JSON, or has no changes |
| 401 | no token, or another |
| 404 | no token was ever made, or it was revoked |
| 413 | more than 500 changes, or a body over a megabyte |
errors gives the index of each change that could not be added, with
what was wrong, by field.
From GitHub
A webhook of your repository tells Tayori of each pull request merged and each release published.
The secret
The admin makes the webhook's secret in Settings, and sees it once, to
paste into GitHub. Tayori keeps it encrypted, as the AI key: it needs the
:vault or the :secret_key_base option. While there is none, the
route answers 404.
The raw body
GitHub signs the body as it sent it, and Tayori checks the signature
against it. A Phoenix endpoint parses JSON before the router, so the body
is gone when the route is reached. Give your endpoint's Plug.Parsers
Tayori's body reader, which keeps a copy of the bodies GitHub signs, and
only those:
plug Plug.Parsers,
parsers: [:urlencoded, :multipart, :json],
pass: ["*/*"],
body_reader: {Tayori.GitHub, :read_body, []},
json_decoder: Phoenix.json_library()Without it, each delivery is answered with a 500, and the log says why.
Add the webhook
In the repository on GitHub, Settings → Webhooks → Add webhook:
- Payload URL - the route of the API for machines:
https://myapp.com/api/machines/news/hooks/github. - Content type -
application/json. - Secret - the secret made in Tayori's Settings.
- Which events - Let me select individual events: Pull requests and Releases, and nothing else.
GitHub sends a ping first, which Tayori answers with a 200. Each
delivery is in the webhook's Recent Deliveries, with Tayori's answer: a
200 with the counts, a 202 for an event Tayori does not read, a 401 for
a wrong signature.
What becomes a change
A pull request merged into the default branch is one change: the pull
request's title, and the section of its description headed
## Changelog or ## Release notes, when there is one. Tayori never
takes the rest of the description: it is often for the team. A template
helps:
## Changelog
<!-- What changes for the people who use the app, in their words. Leave empty if nothing. -->A pull request closed without a merge is nothing; so is one merged into another branch.
Its labels say more, with a prefix (changelog: by default):
| Label | |
|---|---|
changelog:skip | no change |
changelog:internal | for the team only, never in an entry |
changelog:public | for everyone |
changelog:highlight | worth leading with |
changelog:category:<key> | its category |
changelog:segment:<segment> | the readers it is for |
changelog:<kind> | its kind, such as changelog:added |
inbox: [github: [label_prefix: "news:", only_labeled: true]]With only_labeled: true, a pull request with none of these labels is
nothing.
A release published is a change for each item of its notes, read as a
changelog's: an item under ### Added has the kind added, an item
with no section has none. GitHub's own notes work too: the credits after
each item and the list of new contributors are left out. Notes with no
item are one change, titled with the release's name. A prerelease is
nothing until it is released.
Pull requests and releases are both changes of the source "github".
If you take both, label the pull requests changelog:skip, or take one.
From your code
Tayori.Inbox.add/2 adds a change from anywhere in the app: a job that
finishes a migration, a feature flag turned on for everyone.
Tayori.Inbox.add(
%{ref: "flag:timeline", title: "Timeline view", kind: "added", highlight: true},
source: "flags"
)Tayori.Inbox.add_many/2 adds several, and counts them as the route
does. A change added again, with the same source and ref, changes
nothing.
Tayori.Inbox.ingest/0 reads the sources of the :inbox config again;
the app reads them when it starts. In a deploy with Mix, mix tayori.ingest does it and prints each source's count. In a release, ask
the running app:
bin/my_app rpc "IO.inspect(Tayori.Inbox.ingest())"
Not bin/my_app eval: it runs in a new node that does not start the
app, so Tayori has no repo there.
Digests
A digest makes an entry of the changes waiting in the inbox: the new
ones, for the public. A change for the team (audience: "internal"), or
one you put aside, never goes into one. The changes it takes are then
"drafted", with the entry's id, and no digest takes them again.
{:ok, entry} = Tayori.Digest.run()
{:ok, entry} = Tayori.Digest.run(ids: ["0193…", "0194…"])
{:error, :nothing} = Tayori.Digest.run()The admin's inbox and the MCP tools call the same function.
Plain, or written by the model
Without AI, the digest is plain. One change makes an entry of its own: its title and its body. Several make an entry titled with the day, with a section for each category, the highlighted changes first.
With AI set up in the admin's Settings (see Writing with AI), the model writes the entry: a title, a summary and a body in Markdown, in your first language and in the voice of "About your product". It is told to:
- put together the changes that belong together;
- lead with the highlights;
- write for the people who use the product, not its developers: what they can do now, without the names in the code or the numbers of tickets and pull requests;
- say only what the changes say.
The model gets each change's title, body, category and whether it is a highlight. It never gets a change's ref, link or author, nor a change for the team. The entry's categories and segments are the changes', not the model's.
To have the other languages too, translated by the model:
digest: [translate: true]A language the model fails to translate is left for you to write.
When the model fails, or answers in another form, the digest is plain,
and a warning is logged: a digest never waits on the model. To make a
plain one with AI set up, Tayori.Digest.run(ai: false).
Review
A digest is a draft. Read it in the admin, change what it says, and publish it.
When you trust what comes out, publish at once:
digest: [publish: :publish]The entry is then published as it is written, and announced as any
other: on the page, in the app, to webhooks and subscribers when it says
so. Keep :draft while you try the model on your changes.
Cadence
When a digest runs is the :every of the :digest config:
:manual- when you ask, in the admin or withTayori.Digest.run/1. The default.:ingest- each time changes come: after a deploy reads the sources, and after the ingest endpoint or GitHub adds some.{:weekly, day}- each week, on a day (:monday...:sunday;:weeklyalone is Monday).:monthly- on the first day of each month.{:days, n}- n days after the day of the last scheduled digest.
A scheduled digest runs at :at, a time of day in UTC (~T[09:00:00]).
With fewer changes waiting than :minimum (1), it makes nothing, and
the changes wait for the next time. The time is kept all the same, so a
weekly digest with too little to say waits a week, not a minute.
Tayori's scheduler looks every minute, on one node of the cluster. A schedule begins the first time it looks: a weekly digest added on a Wednesday first runs the next Monday, not at once. A time missed while the app was down runs once, at the next look.
With the :oban option, the digest is a job of Tayori.Worker. Asked
for again while one waits, it is the same job, which takes the changes
that came meanwhile. Without Oban, it runs in a task.
Tayori.Digest.status/0 says where it stands: the schedule, the next
time, the last, and how many changes wait.
An app that deploys on each merge
Each deploy reads the sources, and each merge brings a change or two. An entry for each would be too many: gather them for a week, and read the draft before it goes out.
digest: [every: {:weekly, :tuesday}, at: ~T[14:00:00], minimum: 3]A quiet week with fewer than three changes waits for the next.
Release notes with each version
An app that ships versions makes an entry of each one, as it ships:
digest: [every: :ingest]Code that adds changes in batches tells the digest how many were new:
%{added: added} = Tayori.Inbox.add_many(changes, source: "release")
Tayori.Digest.after_ingest(added)A weekly changelog
digest: [every: {:weekly, :friday}, at: ~T[10:00:00], publish: :publish]Published each Friday as the model writes it, when you trust it with your changes.
A monthly changelog for customers
Customers read a month at a time. The model groups the month's changes and leads with what matters:
digest: [every: :monthly, at: ~T[08:00:00], minimum: 5, translate: true]The draft waits for you on the first of the month, in every language.
By hand
digest: [every: :manual]The inbox fills; you choose the changes and make the entry in the admin, when there is something to say.
Emails
With the :subscriptions feature, an entry you choose to email goes to
the subscribers. When it goes is the :emails config.
Each entry
emails: [every: :each]The default: an email for each entry, when it is published, with the whole entry.
A digest email
emails: [every: {:weekly, :monday}, at: ~T[08:00:00]]The entries to email wait. On the day, at the time (UTC), one email lists those published since the last: each one's title, its summary (or the first words of its body) and a link to the public page, the newest first. A period of one entry sends that entry, as it would alone. A period with none sends nothing.
:every is {:weekly, day} (:weekly is Monday), :monthly (the first
day of the month), or {:days, n}. Each entry is sent once: it is
marked emailed_at when the email of its period goes out. An entry for
some readers (with segments) is never emailed, as with each entry.
The email comes in the subscriber's language, with the same look as an
entry's, and a link and a List-Unsubscribe header to leave. With Oban,
each subscriber's email is a job; without, they are sent from a task,
one after the other.
Fewer emails
Publishing and emailing are now apart. You can publish as often as things ship, and the page, the panel and the feeds keep up, while the subscribers hear once a week or once a month. Choose what to email entry by entry: the emails carry what readers would miss, not each fix.
For an app that publishes a digest each week, emails: [every: :each]
is already one email a week. For one that publishes each day, or on each
deploy, a weekly or monthly email keeps the inbox of your readers quiet.
Say so on the page: the words of the subscribe form ("The news, by
email") are yours to change, as subscribeText (see Tayori.Messages):
"Once a month, what changed".
From an agent
The MCP server (Tayori.MCP) gives an agent the same steps: it adds
changes, drafts an entry from the inbox, writes and edits it, puts
screenshots in it, and publishes it when asked. See MCP.