# `Tayori.API`
[🔗](https://github.com/hattoriai/tayori/blob/v0.1.0/lib/tayori/api.ex#L1)

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 (`c: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](automation.md) 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](mcp.md) 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.

---

*Consult [api-reference.md](api-reference.md) for complete listing*
