Tayori.API (Tayori v0.1.0)

Copy Markdown View Source

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
end

Mount 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 after newAfter is 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 :announcements feature, 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 :reactions feature, else a 404.
  • GET /:slug - one entry: {"entry": {...}}, or a 404. It counts a view of the entry from "widget" (the :analytics feature).

<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=1 shows its word.
  • POST /ui/panel - opens the panel on the list, and marks the news seen: {"html", "since"}. since keeps 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 :announcements feature, 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:

  • OPTIONS of each route - the CORS preflight of the sites of the feature.
  • GET /tayori.js - the script of <tayori-news>, an ES module.
  • GET /tayori.css and GET /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 multipart file field, or as the body, with the token of the request_upload tool, 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 a tayori_api behind protect_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.