Tayori — Product news for Phoenix

Tayori (便り, "news, a letter from someone") is product news for Phoenix: what your app has to say to its people. It starts with the changelog. It gives your app:

  • an admin, mounted in your router behind your own admin pipeline, to write the news: each entry in Kotoba's editor, with images, videos and files, in each of your languages, published now or at a time picked with Koyomi;
  • the news in your LiveViews: a button with the count of what each person has not seen, and a panel of the news, live as entries are published;
  • <tayori-news> and a JSON API, for a frontend that Phoenix does not render (React, Vue, Svelte, a mobile app), and for a site on another origin, with a token your server signs for the reader;
  • a public page of the news, a page for each entry, and Atom and JSON feeds;
  • segments: an entry for everyone, or for the readers your app says it is for (a plan, a role, a team), from the users it already has, with no data sent anywhere;
  • drafts with AI, with your own key: an entry from what shipped, a translation, a passage rewritten, streamed into the editor for you to accept;
  • Tayori.Notifier, to send an entry by email (or to Slack) when it is published, through your own mailer;
  • Tayori.Test, to publish entries and read the news in Phoenix.LiveViewTest with no browser.

The admin is a page of its own, with its own script and styles, so it looks the same in any app. The news in your app is a prebuilt JavaScript bundle in the Hex package: your app does not run Node.js for it, you add the hook and one style sheet.

The Tayori admin: an entry with screenshots in Kotoba&#39;s editor, in English and Portuguese, beside its publication, cover and details

The news in a LiveView: the panel of a reader, with category filters, a pinned announcement, covers and reactions

The public page: What&#39;s new in Atelier, with a subscription form, category filters, a search and a pinned entry

What Tayori does

Entriesa title, a summary, a body in Kotoba (headings, lists, links, code, tables, images, videos, files), a cover, categories, a slug; drafts, scheduled, published, unpublished; pinned
Languageseach entry in each of your languages, the first required; each reader gets theirs, else the first
Adminmounted with tayori_admin, behind your pipeline; your resolver says who the admin is and what they may do (all, read only, nothing); a list with tabs, a search and each entry's numbers; a preview as the page, the panel or the announcement; categories; live as other admins write
Publishingnow, or at a time in the admin's own time zone; each entry announced once, on one node of a cluster, to every open page, the subscribers, the webhooks and your notifier; as Oban jobs, retried through deploys, when your app has Oban
Extrasyours to turn on: reactions, announcements, analytics, email subscriptions, webhooks and Slack and Discord, the embed
ReadersTayori.Reader, built by your resolver from your own user: an id, segments, a language, the time they joined
In the appa button with the count of unread entries, a panel with category filters, cards and each entry whole inside it; announcements as a popup, a toast or a banner; live over PubSub, and across tabs
Any frontenda JSON API (tayori_api) and <tayori-news>, the same button, panel and announcement; on another origin with the :embed feature: CORS for your sites and a signed reader token
Public pagetayori_page: the news for everyone, with filters, a search, pinned entries and email subscriptions; a page for each entry with previous and next; Atom and JSON Feed; shared-link cards, JSON-LD and a sitemap; in the reader's language
Automationan inbox of what shipped: your CHANGELOG.md and fragments at each deploy, CI with a token, GitHub's merged pull requests and releases, your code; the team's changes kept out; digests, plain or by the model, when you ask, as changes come, or each week or month; drafts or published; emails each entry or once a period
MCPTayori.MCP, mounted with Sake beside your app's other servers: an agent reads the news and the inbox, drafts and writes entries with screenshots, and publishes when asked
AIany model of ReqLLM with your key, kept encrypted; draft, translate, rewrite, shorter, warmer, fix the spelling; suggestions you accept or reject
Wordseight languages (Tayori.Messages), your own in the config
DatabasePostgreSQL and SQLite, in a migration of your app (Tayori.Migrations)
ThemingCSS custom properties, light and dark, the Sumi theme, forced colours, reduced motion; the admin in Tayori's theme or Sumi's

Install

Tayori needs Elixir 1.18 or later (it uses the built-in JSON module), Phoenix 1.7.14 or 1.8, Phoenix LiveView 1.1 or later, and Ecto with PostgreSQL or SQLite.

With Igniter:

mix igniter.install tayori
mix ecto.migrate

The installer adds the dependency, mounts the admin at /admin/news and the public page at /changelog, writes the migration of Tayori's tables, adds the hook to assets/js/app.js and the style sheets to assets/css/app.css, starts Tayori in your application, and writes lib/my_app_web/tayori_resolver.ex, which lets no one in the admin until you say who your admins are. It shows each change as a diff before it writes, and running it again changes nothing.

Without Igniter, add {:tayori, "~> 0.1"} (and {:req_llm, "~> 1.6"} for AI) to your deps, and Tayori to your supervision tree, after the repo and the PubSub:

{Tayori,
 repo: MyApp.Repo,
 pubsub: MyApp.PubSub,
 secret_key_base: Application.fetch_env!(:my_app, MyAppWeb.Endpoint)[:secret_key_base],
 locales: ["en", "pt-PT"]}

The Quickstart goes through it all, with the manual steps.

Use

Mount the admin behind your admin pipeline, and the public page:

import Tayori.Router

scope "/admin" do
  pipe_through [:browser, :require_admin]
  tayori_admin "/news", resolver: MyAppWeb.TayoriResolver
end

scope "/" do
  pipe_through :browser
  tayori_page "/changelog", title: "What's new in MyApp"
end

Say who your admins and your readers are:

defmodule MyAppWeb.TayoriResolver do
  @behaviour Tayori.Resolver

  @impl true
  def resolve_user(conn), do: conn.assigns[:current_admin]

  @impl true
  def resolve_access(%MyApp.Admin{}), do: :all
  def resolve_access(_nobody), do: {:forbidden, "/admin/login"}

  @impl true
  def segments, do: [{"Plan", [{"plan:free", "Free"}, {"plan:pro", "Pro"}]}]

  @impl true
  def resolve_reader(%{assigns: %{current_scope: %{user: %{} = user}}}) do
    %Tayori.Reader{id: "user:#{user.id}", segments: ["plan:#{user.plan}"], locale: user.locale, since: user.inserted_at}
  end

  def resolve_reader(_visitor), do: nil
end

And put the news in your LiveViews: the hook in the live_session, after your own, and the button and the panel in the layout:

live_session :app,
  on_mount: [{MyAppWeb.UserAuth, :require_authenticated}, {Tayori.Live, resolver: MyAppWeb.TayoriResolver, page: "/changelog"}] do
  ...
end
<Tayori.Components.news_button tayori={@tayori} />
<Tayori.Components.news_panel tayori={@tayori} />

For a frontend that Phoenix does not render, mount tayori_api and use <tayori-news api="/api/news"> (see Any frontend).

Guides

  • Quickstart: from the dependency to the first entry your readers see.
  • The admin: mounting it behind your authentication, access, segments, attachments, the CSP.
  • Readers: the resolver, segments, languages, what is unread.
  • In the app: the news in your LiveViews.
  • Any frontend: the JSON API and <tayori-news>.
  • The public page: the page, each entry's page, the feeds.
  • Features: categories, covers, pinned entries, and the extras you turn on: reactions, announcements, analytics, email subscriptions, webhooks, the embed.
  • Writing with AI: the model, the key, drafts, translations, rewrites.
  • Automation: the inbox, its sources, CI and GitHub, digests, their cadence, the emails.
  • MCP: the news and the inbox for an agent, with Sake; screenshots in entries.
  • Notifications: what a publication sends, with Oban or not.
  • Testing: Tayori.Test.
  • Theming: the properties, dark mode, the Sumi theme.
  • Known limits.

Development

The scripts of the news and of the admin are TypeScript in assets/. The built files in priv/static are not in the repository; the Hex package has them.

mix deps.get
mix dev

mix dev builds the bundles when they are missing (mix tayori.build), and starts a development server at http://localhost:4099: Atelier, a small app with its news, the admin at /admin/news (and in Sumi at /sumi/news), a LiveView page and a plain page with the news, and the public page at /changelog. ?as= chooses who you are: admin, viewer, pro, free or visitor. The scripts rebuild as you change them.

Tests

mix test                    # on SQLite, and PostgreSQL when it is reachable
mix test --include build    # with the built bundles (after mix tayori.build)
mix test.js                 # the scripts' unit tests
mix test.e2e                # the browser tests (Playwright)
mix precommit
mix dialyzer

The browser tests need their browsers once: cd e2e && npm ci && npx playwright install chromium firefox webkit.

Release

mix release (in this repository, an alias that replaces Mix's release task) builds the bundles, checks priv/static, publishes to Hex, and tags and pushes v<version>.

Contributing

See CONTRIBUTING.md: when a change needs an issue first, and what a pull request needs. Report a security problem privately, as SECURITY.md says.

License

MIT, see LICENSE. The admin's script has Phoenix, Phoenix LiveView, Kotoba (with Lexical) and Koyomi in it. Tayori's icons are hand-picked from Lucide (ISC, some from Feather, MIT), and the Sumi theme carries the Outfit and Cormorant Garamond typefaces (SIL Open Font License). Their licences are in NOTICE.