The news for a frontend that Phoenix does not render: a React, Vue or Svelte app served by Phoenix or beside it, a page of plain HTML, a mobile app, a site on another origin. Tayori gives it a JSON API and an element that uses it, <tayori-news>.

The JSON API

Tayori.Router.tayori_api/2 mounts it. Mount it behind a pipeline that accepts JSON and has your session, so that the resolver finds your user in the conn's assigns:

pipeline :api_session do
  plug :accepts, ["json"]
  plug :fetch_session
  plug :protect_from_forgery
  plug :fetch_current_scope_for_user
end

scope "/api" do
  pipe_through :api_session
  tayori_api "/news", resolver: MyAppWeb.TayoriResolver
end

Each entry's url is its page on your tayori_page of the same router, whole; page: gives another (page: "https://myapp.com/changelog").

The routes:

GET /api/newsthe newest entries, the pinned ones first; ?before=<id> for the next page, ?limit= (10, up to 50), ?category=<key>, ?q=
GET /api/news/unreadthe count of unread entries, cheap enough to ask often
POST /api/news/seenmarks the news seen, now
GET /api/news/categoriesthe categories, in their order
GET /api/news/:slugone entry, counted as a view from "widget" (the :analytics feature)
POST /api/news/:id/reactionsturns the reader's reaction on or off (the :reactions feature)
GET /api/news/announcementthe entry announced to the reader now, or null (the :announcements feature)
POST /api/news/announcement/:id/dismissdismisses it, on every device
{
  "entries": [
    {
      "id": "0193…",
      "slug": "exports-in-two-clicks",
      "title": "Exports in two clicks",
      "summary": "Zip a project, with its files and notes.",
      "cover": {"url": "/uploads/export.png", "alt": "The Export button", "width": 1600, "height": 900},
      "categories": [{"key": "new", "name": "New", "color": "green"}],
      "pinned": false,
      "publishedAt": "2026-10-20T09:00:00Z",
      "locale": "en",
      "html": "<p>Every project has an <strong>Export</strong> button.</p>",
      "text": "Every project has an Export button.",
      "url": "/changelog/exports-in-two-clicks",
      "reactions": {"counts": {"πŸŽ‰": 3}, "mine": ["πŸŽ‰"]}
    }
  ],
  "more": false,
  "unread": 1,
  "newAfter": "2026-10-19T17:12:00Z",
  "signedIn": true,
  "locale": "en",
  "page": "/changelog",
  "features": ["reactions", "announcements"],
  "emoji": ["πŸ‘", "πŸŽ‰", "❀️", "πŸš€", "πŸ‘€"],
  "messages": {"title": "What's new", "...": "..."}
}

html is the body as Kotoba renders it on the server: every text escaped, and no HTML of the author's own. The names of the categories are in the entry's language. newAfter is the time after which an entry is new to the reader (nil for a visitor). features are the extras a frontend shows, reactions and announcements, with the emoji of the reactions; an entry has reactions only with the feature. messages are the words around the news in the reader's language. /unread gives the same, with the count and no entries.

?category= and ?q= are those of Tayori.news/2: the entries of a category, and those with every word of the search in their title, summary or body, in any language. The pinned entries are on the first page only, besides ?limit=: the next page, ?before= the id of the last entry that is not pinned, has none.

A reaction is the emoji in the body, as JSON or a form:

curl -X POST /api/news/0193…/reactions -H 'content-type: application/json' -d '{"emoji": "πŸŽ‰"}'
{"reactions": {"counts": {"πŸŽ‰": 4}, "mine": ["πŸŽ‰"]}}

A visitor gets a 401 ({"error": "unauthorized"}), and an emoji that is not one of yours a 422 ({"error": "unknown_emoji"}).

The announcement is an entry with how it is shown, until when, and its button:

{
  "announcement": {
    "id": "0193…",
    "title": "Timeline view",
    "...": "...",
    "announce": "toast",
    "until": "2026-10-27T09:00:00Z",
    "cta": {"url": "/timeline", "label": "Try it"}
  }
}

announce is popup, toast or banner; cta is nil when it has no button, and a nil label is the word tryIt of the messages. A visitor's dismissal is not kept: the page hides it until it closes.

A feature that is off has no route: its paths answer 404.

With protect_from_forgery, a POST needs the CSRF token of the session in the x-csrf-token header.

<tayori-news>

The script of the news defines <tayori-news>: the button, the panel and the announcement of the LiveView. The markup is the LiveView's own: the API renders Tayori.Components for the reader (its /ui routes), and the element puts it in the page and answers its clicks, keys and the click outside as LiveView would. A change to the panel is a change to both.

<tayori-news api="/api/news"></tayori-news>
import "tayori"
  • api - the path or the address of the JSON API (src is its older name).
  • label - show the word beside the icon.
  • poll - how often to ask the count again, in seconds (300; 0 never). It also asks when the page comes back into view.
  • reader-token - the reader's token, on another origin (below).
  • banner - where an announcement shown as a banner goes, a CSS selector (the start of the <body> when not given). A popup and a toast are fixed to the page.

The panel has the categories to filter by, and the entries as cards: the cover, the pin, the New mark, the categories, the date, the title, the summary and the reactions. A card opens its entry in the panel, whole, with its reactions to press when the reader is signed in. Escape goes back to the list, then closes, and a click outside closes; the focus goes to the back button, back to the card, and back to the button. The announcement steps aside while the panel is open, as in the LiveView.

The panel's links to the public page are the API's: its :page, else the tayori_page of the same router.

It reads the CSRF token from <meta name="csrf-token"> for its POSTs on the app's own origin. It renders into the page, not a shadow root, with the markup of the LiveView components, so tayori.css (and kotoba.css, and tayori-sumi.css for Sumi) style it, in light and dark, and your CSS can too.

It sends tayori:open and tayori:close events, which bubble, and tayori:token-expired (below).

In React

import "tayori"

export function Header() {
  return (
    <header>
      <tayori-news api="/api/news" />
    </header>
  )
}

Your own components

The JSON API is the whole contract: a frontend can render the news with its own components, and call /unread and /seen itself.

Another origin

A page on another origin than your app's, a help centre or a marketing site, has no session of your app: the browser keeps your app's cookies for your app's origin. The :embed feature gives such a page the news, with a token your server signs for the reader.

The feature

Name the origins of the sites, each a scheme and a host, with a port if not the default. Tayori signs the tokens with the app's secret:

{Tayori,
 repo: MyApp.Repo,
 pubsub: MyApp.PubSub,
 secret_key_base: Application.fetch_env!(:my_app, MyAppWeb.Endpoint)[:secret_key_base],
 features: [:embed],
 embed: [origins: ["https://www.atelier.app", "https://help.atelier.app"]]}

max_age sets how long a token is valid, in seconds (a day): embed: [origins: [...], max_age: 3600].

The mount

Mount the API again for the sites, behind a pipeline with no session and no CSRF protection: a request from another origin carries no cookie of your app, so it has nothing to forge, and its reader comes with the token. :as names the second mount, and :page is the address of your public page, for the links of the entries:

pipeline :embed do
  plug :accepts, ["json"]
end

scope "/embed" do
  pipe_through :embed

  tayori_api "/news",
    resolver: MyAppWeb.TayoriResolver,
    page: "https://atelier.app/changelog",
    as: :tayori_embed
end

Without a token, the API asks your resolver, with a conn that has no session: read your user from the assigns, as the installer's resolver does, and give nil (a visitor) when there is none.

With the feature, the API answers the sites' CORS preflights and gives their requests their origin back (access-control-allow-origin, with vary: origin). It never allows credentials, and it answers no other origin: a browser on another site cannot read it. Your app's own mount answers the sites too, but behind protect_from_forgery its POSTs need your session's CSRF token: give the sites the second mount.

The page

The API serves the element's script and its style sheets, with Kotoba's for the bodies:

<link rel="stylesheet" href="https://app.atelier.app/embed/news/tayori.css" />
<script type="module" src="https://app.atelier.app/embed/news/tayori.js"></script>

<tayori-news
  api="https://app.atelier.app/embed/news"
  reader-token="SFMyNTY.g2gDdAAAAAR…"
></tayori-news>

tayori-sumi.css is the Sumi theme's. The script is an ES module with no imports, priv/static/tayori.esm.js of the Hex package: a site can also serve its own copy, or bundle tayori from npm with its own scripts. The files are cached for an hour, and change with Tayori.

The Embed card of the admin's settings has the snippet, with the address of your app's API.

The reader token

The server that renders the page signs the token of the person it is for, with the reader your resolver gives:

<tayori-news api={@news_api} reader-token={Tayori.Embed.token(@reader)}></tayori-news>

Tayori.Embed.token/1 puts the reader's id, segments, language and the time they joined in a Phoenix.Token, signed with your secret_key_base and a salt of Tayori's own. No one can make one, or change the reader in it, without your secret, and Tayori never takes a reader's id from the browser any other way. Make it on the server for the signed-in person, never from an id the page sends. A visitor needs none: without reader-token, the page shows the news for everyone.

When the page is rendered by another server than your app's, that server asks your app for the token, from its backend, through an endpoint of your own behind your API's authentication, which answers Tayori.Embed.token(reader).

The element sends it as authorization: Bearer <token>. A token older than max_age is a 401, {"error": "token_expired"}: the element sends tayori:token-expired, goes on as a visitor, and takes a new token when the page sets reader-token again:

document.addEventListener("tayori:token-expired", async (event) => {
  const {token} = await (await fetch("/news-token")).json()
  event.target.setAttribute("reader-token", token)
})

A bearer token that is not Tayori's (your own API's, say) is left to your resolver.