The news as JSON, for a frontend that Phoenix does not render (React,
Vue, Svelte, a mobile app), and as HTML for <tayori-news>, the
element of Tayori's script. Mounted with Tayori.Router.tayori_api/2:
scope "/api" do
pipe_through [:api_session]
tayori_api "/news", resolver: MyAppWeb.TayoriResolver
endMount it behind a pipeline that has the app's session (fetch_session,
and the app's own plug that assigns the user): the resolver reads the
reader from the conn (Tayori.Resolver.resolve_reader/1). A visitor
gets the entries for everyone, and nothing is unread. On another
origin, a reader comes with a signed token instead (the :embed
feature, Tayori.Embed).
The routes
GET /- the newest entries, the pinned ones first:{"entries": [...], "more": bool, "unread": n, "newAfter": time, "signedIn": bool, "locale": tag, "page": url, "features": [...], "emoji": [...], "messages": {...}}. An entry published afternewAfteris new to the reader (nil for a visitor).?before=<id>gives the entries published before that one, with no pinned ones;?limit=up to 50 (10);?category=<key>the entries of a category;?q=those with every word in their title, summary or body.GET /unread-{"unread": n, "signedIn": bool, "locale": tag, "features": [...], "emoji": [...], "messages": {...}}, cheap enough to ask often.POST /seen- marks the news as seen, now:{"unread": 0}.GET /categories-{"categories": [{"key", "name", "color"}]}, in their order, named in the reader's language.GET /announcement- the entry announced to the reader now:{"announcement": {...}}, or{"announcement": null}. With the:announcementsfeature, else a 404.POST /announcement/:id/dismiss- dismisses it for the reader, on every device:{"dismissed": true}. A visitor's is not kept.POST /:id/reactions- turns the reader's reaction on or off, with{"emoji": "🎉"}as the body:{"reactions": {"counts": {...}, "mine": [...]}}. A 401 for a visitor, a 422 for an emoji that is not one of the app's. With the:reactionsfeature, else a 404.GET /:slug- one entry:{"entry": {...}}, or a 404. It counts a view of the entry from"widget"(the:analyticsfeature).
<tayori-news> reads the panel as HTML: the components of
Tayori.Components, rendered from the same state as in a LiveView
(Tayori.Live.State), in {"html": ...}. The element answers their
phx-click, so the markup is one, wherever the panel is shown:
GET /ui/button- the button, with the reader's count of unread entries:{"html", "unread"}.?label=1shows its word.POST /ui/panel- opens the panel on the list, and marks the news seen:{"html", "since"}.sincekeeps the New marks of the opening in the panel's other views.GET /ui/panel- a view of the open panel:?category=<key>,?pages=(the pages of the list shown, up to 20),?entry=<id>(an entry of the list, or the announcement, opened whole; a view from"widget"),?since=:{"html", "entry"}, or a 404 for an entry the reader does not see.GET /ui/announcement- the announcement of the moment:{"html", "id", "kind"}, all nil when there is none. With the:announcementsfeature, else a 404.
For machines, not readers, with no session (see the Automation guide):
POST /changes- changes for the inbox, with the ingest token (Tayori.Ingest).POST /hooks/github- GitHub's webhook, signed with its secret (Tayori.GitHub).
With the :embed feature, also:
OPTIONSof each route - the CORS preflight of the sites of the feature.GET /tayori.js- the script of<tayori-news>, an ES module.GET /tayori.cssandGET /tayori-sumi.css- its style sheets, in Tayori's theme or Sumi's, with Kotoba's for the bodies.
An entry is:
{
"id": "0193…",
"slug": "exports-in-two-clicks",
"title": "Exports in two clicks",
"summary": "Zip a project, with its files and notes.",
"cover": {"url": "…", "alt": "…", "width": 1600, "height": 900},
"categories": [{"key": "new", "name": "New", "color": "green"}],
"pinned": false,
"publishedAt": "2026-10-20T09:00:00Z",
"locale": "en",
"html": "<p>…</p>",
"text": "…",
"url": "https://myapp.com/changelog/exports-in-two-clicks",
"reactions": {"counts": {"🎉": 3}, "mine": ["🎉"]}
}cover is nil when it has none; html is the body as Kotoba renders
it (escaped, with no HTML of its own); url the entry on the public
page, whole, when there is one (the :page option, else the
tayori_page of the same router); reactions is there with
the :reactions feature. An announcement is an entry with
"announce" ("popup", "toast" or "banner"), "until" and
"cta" ({"url", "label"} or nil). messages are the words around
the news in the reader's language (Tayori.Messages), and features
the extras a frontend shows ("reactions", "announcements"), with
the emoji of the reactions.
With the :mcp config (Tayori.MCP), also:
POST /uploads/:token- an image for an entry, from an agent: the file in a multipartfilefield, or as the body, with the token of therequest_uploadtool, good for one file for ten minutes:{"url", "markdown", "name", "contentType", "bytes", "width", "height"}, a 201. A 404 for a token that is not good (unknown, used, past its time), a 413 for a file over 25 MB, a 415 for a file whose bytes are not a PNG, JPEG, GIF or WebP image. The token is the request's only credential: no session, no CSRF token (see the MCP guide for atayori_apibehindprotect_from_forgery).
An error is {"error": code}: "not_found", "unauthorized" (a
visitor reacts), "unknown_emoji", "token_expired" (a reader token
older than its :max_age, a 401).
Behind the app's protect_from_forgery, a POST needs the CSRF token
of the session in the x-csrf-token header, as <tayori-news> sends
it.