Files
ipx/docs/signals.md
rays 7627af5bc8 Signals in the needle's orange, and a larger DeadAir tile in the play bar (#268)
Each Signal's tile, icon or initials, is tinted --accent2 in place of
--accent: the orange point of the Signals folder's own icon, so the
folder and what is in it read as one. The player's DeadAir tile goes
from 22px to 26px in its 30px button.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 20:11:25 +00:00

17 KiB

Signals

A Signal is a feed someone makes from their own subscriptions (#254). They give it a name, the words to listen for (formula 1, f1) and, when those are not enough, a sentence saying what they mean (#259). TypeSafe's Jev reads each item of the feeds they subscribe to and picks out the ones about that subject: the name, the words and the sentence together, not any one word. Those items show in the Signal as well as in their own feed. The rest stays noise, where it was.

Each person brings their own TypeSafe key, entered in Settings. Nothing is judged for someone without one. Jev is asked once for each item, on that person's key, with one question for each of their Signals that has not heard about the item yet.

What someone sees

  • The sidebar. A Signals folder comes first in the feed list, under All Subscriptions (#255, #256). It is closed or open as an OPML's folder is, remembered in each browser. Its row is like any folder's (#265); New signal is the + on the folder's page. Inside the folder are the person's Signals, each with its items and unread counted under its name.
  • An icon of its own (#261), chosen in the Signal's dialog from all of Font Awesome Free 7.3.1, the set the page's own icons come from: 2,883 icons, solid, regular and brands, searched by name and Font Awesome's search terms ("ai" finds the robot). Without one, a Signal shows its initials. Auto asks Jev to choose (#263), and a Signal made without an icon gets Jev's pick by itself, a moment after it is made. Every Signal's tile is in the needle's colour (--accent2), the orange point of the folder's icon.
  • Each Signal's row says "12 items · 3 unread", as a feed's row counts its own.
  • The folder's page lists the Signals, as an OPML's page lists its feeds, with the + for a new one.
  • A Signal's page is an ordinary item list: All, Unread, Downloaded and Pinned, search and sort. Its header says what it listens for (the sentence, or the words), and that nothing is picked out without a key. Edit changes the name, the words or the sentence; Delete removes the Signal and leaves the items in their feeds.
  • Read state is the item's. Reading an item in a Signal reads it in its feed, and the other way round. The counts follow without a reload (#258).
  • Settings, TypeSafe key. A password field. A key saves on leaving the field, and the bin forgets it. The key is never sent back to the page, which only learns whether one is set.

How an item gets into a Signal

sequenceDiagram
    autonumber
    participant W as Scan worker
    participant J as Signals judge<br/>(signals::run)
    participant DB as Database
    participant T as TypeSafe<br/>api.typesafe.ai
    participant P as The page

    W->>DB: new entries stored
    W->>J: ctx.signals.notify_one()
    Note over J: also woken by a new Signal,<br/>new keywords, a key saved,<br/>or every 5 minutes
    J->>DB: unjudged(skip refused keys, 50)
    DB-->>J: items, each with the Signals<br/>that have not heard of it
    loop each item
        J->>T: POST /v1/systemone<br/>one noul question per Signal
        T-->>J: answers: noul 0..1 per question
        J->>DB: record_verdicts (hit = noul >= 0.5)
    end
    J-->>P: signal_hits event (SSE), once a pass per person
    P->>DB: GET /api/signals (counts),<br/>and the open Signal's list

The judge is a task of its own beside DeadAir's (crates/ipx/src/signals.rs), not a job on the scan worker, so a slow TypeSafe never holds up a scan.

Which items are asked about

Db::unjudged finds, newest first, up to 50 items that are:

  • in a feed the Signal's owner subscribes to,
  • published (or, with no date, first seen) in the last 30 days (SIGNAL_DAYS),
  • not hidden from them by their block lists,
  • not yet in signal_verdicts for that Signal,
  • for someone with a key that TypeSafe has not refused since the daemon started.

So a new Signal looks back a month, and after that sees each new item. A show subscribed to today has its back catalogue skipped, apart from its last month.

Rows for one item and one person are grouped, so an item is one call whatever the number of Signals. A full batch means there may be more, and the next pass starts at once. Otherwise the judge sleeps until woken or for 5 minutes.

Every answer is kept

signal_verdicts records each answer, fitting or not. An item with no row has not been asked yet; an item with a row is never asked again. So the cost is one call per item per person, once. Changing a Signal's name, words or sentence deletes its verdicts, and everything in the window is asked again. The first start with sentences (#259) deleted every verdict, since they answered the old question.

TypeSafe

Jev is TypeSafe's System One model, already used to pick a feed's Directory category (feed::classify). The address is feed::JEV_URL, https://api.typesafe.ai/v1/systemone. Signals use the noul question type, which answers how likely a statement is true, from 0 to 1. Questions in one call are judged independently and in parallel.

A request

Sent with Authorization: Bearer <the person's key>, for an item and two of their Signals:

{
  "model": "jev-latest",
  "state": "Feed: Pit Lane\nTitle: Monaco qualifying\n\nQualifying report from the Monaco Grand Prix.",
  "questions": {
    "signal_3": {
      "type": "noul",
      "instructions": "The reader follows a subject they call \"F1\", described by these words: formula 1, f1. Is this item about that subject?",
      "criteria": {
        "true": "The item's main subject is that subject, as the name, the words and the reader's description together describe it.",
        "false": "It is about something else, or touches the subject only in passing."
      }
    },
    "signal_4": {
      "type": "noul",
      "instructions": "The reader follows a subject they call \"Food\", described by these words: baking. In their own words: Bread and cakes, not restaurants. Is this item about that subject?",
      "criteria": {
        "true": "The item's main subject is that subject, as the name, the words and the reader's description together describe it.",
        "false": "It is about something else, or touches the subject only in passing."
      }
    }
  }
}
  • state is text: the feed's title, the item's title, and its show notes with the HTML taken out, cut to 2,000 characters (TEXT_CHARS).
  • Each question is keyed signal_<id>, and its answer comes back under the same key.
  • criteria is an object saying what true and false mean. A sentence there is refused with a 422 (#257).
  • One subject, not any word. The first question was "Is this item about any of these: ai, development, programming?", so one broad word was enough: AI Development took Sega pulling a game remaster (0.90), a Surface Laptop review (0.95) and a C++ tutorial (0.99), 54 items in all. Asked about the subject, with the sentence "AI tools for writing software: coding agents, AI dev tools, vibe coding, AI in code review.", those scored 0.06, 0.16 and 0.02, and 5 of the 54 stayed in, AI code review in the Linux kernel highest at 0.94 (#259).

Its answer

What TypeSafe answered to a request of this shape, on 2026-10-16:

{
  "model": "jev-1.13.0",
  "answers": {
    "signal_3": { "type": "noul", "noul": 0.96 },
    "signal_4": { "type": "noul", "noul": 0.02 }
  },
  "usage": { "input_tokens": 389, "output_tokens": 40 }
}

0.5 or more is a fit (THRESHOLD): the item goes into Signal 3 and not Signal 4. A question missing from the answer is left unjudged and asked again next pass.

When TypeSafe says no

TypeSafe answers What the judge does
401, 402, 403 The key is refused. That person is passed over until they save a key or the daemon restarts, so a bad key is not sent the whole queue. Logged once: "TypeSafe refused a Signal key".
400, 422 The request itself is wrong, so every item's would be. The pass ends; the items wait for the next one. Logged: "TypeSafe refused a Signals request as malformed", with TypeSafe's reason.
anything else, or no answer That item is left unjudged and asked again next pass. Logged: "Jev could not judge an item".

The error keeps TypeSafe's reply, cut to 500 characters, for example:

TypeSafe answered 422: {"detail":[{"type":"model_attributes_type","loc":["body","questions","3","noul","criteria"],"msg":"Input should be a valid dictionary or object to extract fields from","input":"Yes if the subject is one of them."}]}

The API

Every route is the signed-in person's own. A Signal of someone else's is not found, as one that does not exist.

GET /api/signals

[
  { "id": 1, "name": "AI Development", "keywords": ["ai", "development", "programming"],
    "description": "AI tools for writing software: coding agents, AI dev tools, vibe coding, AI in code review.",
    "icon": "solid/laptop-code", "items": 48, "unread": 5 },
  { "id": 2, "name": "Apple", "keywords": ["apple", "mac", "iphone"], "description": null, "icon": null, "items": 272, "unread": 0 }
]

items counts the items Jev picked out that are still in a subscribed feed and not hidden, what its list shows; unread those of them not read.

POST /api/signals

{ "name": "Formula 1", "keywords": ["formula 1", "f1"], "description": "Grand prix racing, not road cars." }

Answers { "id": 3 } and wakes the judge. A name is 1 to 100 characters. Between 1 and 50 words are allowed, each up to 200 characters, kept once whatever their case. description is optional, up to 500 characters. Anything else is a 400 saying so.

PATCH /api/signals/{id}

Any of the three: { "name": "F1" }, { "keywords": ["f1", "grand prix"] }, { "description": null } (null or empty clears it), { "icon": "solid/robot" }. A change to the name, words or sentence drops the Signal's verdicts and wakes the judge, since each is part of the question; a new icon does not. The page sends only what changed. An icon is solid/, regular/ or brands/ and a plain name, since the page puts it into an address: anything else is a 400. Answers 204, or 404 for a Signal that is not yours.

DELETE /api/signals/{id}

Answers 204. The Signal and its verdicts go; the items stay in their feeds.

GET /api/signals/{id}/entries

The same query as a feed's list, offset, limit, filter, q, sort, dir:

GET /api/signals/3/entries?filter=unread&sort=published&dir=desc&limit=50
{
  "total": 1,
  "entries": [
    {
      "guid": "sig-1", "feed_id": "pit-lane", "title": "Monaco qualifying",
      "link": "https://pitlane.example/monaco", "published": 1792170000,
      "description": "<p>Qualifying report…</p>", "read": false, "flagged": false,
      "image": null, "duration": null, "episode": null, "season": null, "position": 0,
      "enclosures": []
    }
  ]
}

Reading or pinning one is the item's own route, POST /api/entries/{feed_id}/{guid}/flags, as anywhere else.

PATCH /api/me, the key

{ "typesafe_key": "ts_…" }

A string saves the key and lets the judge try it again. null or an empty string forgets it. A key is up to 500 printable characters. GET /api/me says "typesafe_key_set": true and nothing more.

POST /api/signals/icon

Jev's pick of an icon, on the server's TYPESAFE_KEY, not the person's: it is the page's own doing, once a Signal. Jev chooses among options and cannot name one, so the page searches Font Awesome's index for each word of the name, words and sentence, the best six each, and sends up to 40:

{
  "name": "AI Development",
  "keywords": ["ai", "development", "programming"],
  "description": "AI tools for writing software: coding agents, AI dev tools, vibe coding, AI in code review.",
  "candidates": [["solid/robot", "Robot: ai, android, bot, machine"], ["solid/laptop-code", "Laptop Code: computer, cpu, dell, demo"], ["solid/code", "Code: brackets, code, development, html"]]
}

ipx asks a choice question, each candidate a choice and its label and terms what it means:

{
  "model": "jev-latest",
  "state": "Signal: AI Development\nListens for: ai, development, programming\nIn their words: AI tools for writing software: coding agents, AI dev tools, vibe coding, AI in code review.",
  "questions": { "icon": {
    "type": "choice",
    "instructions": "Which icon best stands for this subject in a list of the reader's topics?",
    "criteria": { "solid/robot": "Robot: ai, android, bot, machine", "solid/laptop-code": "Laptop Code: computer, cpu, dell, demo" }
  } }
}

TypeSafe answered, on 2026-10-16, for six candidates:

{ "model": "jev-1.13.0", "answers": { "icon": { "type": "choice", "choice": "solid/laptop-code", "confidence": 0.34,
  "probabilities": { "solid/robot": 0.4, "solid/code": 0.14, "solid/laptop-code": 0.45, "solid/brain": 0.01, "solid/microchip": 0.0, "solid/terminal": 0.0 } } } }

The answer is { "icon": "solid/laptop-code" }, only ever one it was offered. Each candidate has to be an icon name the API takes, 1 to 60 of them. 404 when the server has no key, 502 when Jev fails (logged: "Jev could not pick an icon"). The call has a jev_icon span.

The event

Once a pass, for each person it found items for, the judge sends this on the event stream (/api/events):

{ "ev": "signal_hits", "user": 2, "hits": 3 }

A page with Signals fetches /api/signals again, and the open Signal's list.

Data

erDiagram
    users ||--o{ signals : owns
    signals ||--o{ signal_verdicts : "has heard of"
    entries ||--o{ signal_verdicts : "judged in"
    users {
        bigint id
        text typesafe_key "NULL: no key"
    }
    signals {
        bigint id
        bigint user_id
        text name
        text keywords "JSON array"
        text description "NULL: none"
        text icon "solid/robot, NULL: initials"
        bigint created
    }
    signal_verdicts {
        bigint signal_id "PK"
        text feed_id "PK"
        text guid "PK"
        bool hit
    }
  • signals and signal_verdicts are created from their entities like every table (crates/ipx/src/entity.rs). users.typesafe_key, signals.description and signals.icon were added to existing tables, so create_missing adds each column when it is missing.
  • The icons are files of ipx-web's, not the database's: web/build.mjs copies Font Awesome Free's SVGs to /icons/<style>/<name>.svg, kept a day, and writes /icons/index.json, names, labels and search terms, about 90 KB compressed, named by its hash and fetched when the picker first opens. A Signal's tile draws its one file as a CSS mask, so it takes the theme's colour.
  • A Signal's list is entries_where with EXISTS (SELECT 1 FROM signal_verdicts v WHERE v.signal_id = $n AND v.feed_id = e.feed_id AND v.guid = e.guid AND v.hit) on top of the subscribed-feeds scope. So filters, search, sort, block lists and read state all work as for All Subscriptions.
  • A verdict for a Signal deleted while Jev was being asked is not stored. Postgres's foreign key would refuse it, and SQLite would keep it for nothing.

Watching it

Traces A signals span for each pass (entries), and a jev span for each call (user, signal_count, http.response.status_code, and TypeSafe's reply as error when it refuses).
Log, counted ev="signal", "an item fits a Signal", for each item with a fit: user, feed, new (how many of their Signals it fits).
Log, warnings The three in the table above.
Events signal_hits on the socket and /api/events.

Limits

  • The key is kept in plain text in users.typesafe_key, because every call needs it whole. It should be encrypted with a server key if the database is ever shared with anything else.
  • Refused keys are remembered in memory, so a restart tries each once again.
  • Keywords changed while an item is being judged can leave that one item's verdict from the old words.
  • There is no meter of how many calls a key has made. It is one call per item in the person's feeds in the window: a person with 1,500 feeds busy enough to publish 3,000 items a month spends about 3,000 calls the first month and then as many as come in.
  • Only the last 30 days are ever judged, by publish date.

Testing

  • cargo test -p ipx: the request's shape and the threshold (signals::tests), and the queue, verdicts, ownership, unread counts and new keywords (db::tests::a_signal_shows_what_jev_said_fits_and_only_to_its_owner), on SQLite and Postgres both.
  • node --test tests/page-unit.js: a Signal's place id and its list's address.
  • npx playwright test -g Signal: makes a Signal from the folder's +, and checks that the matching item shows and that reading it reads it in its feed and clears the badge. TypeSafe is stood in for by the fixture server's /jev (IPX_TYPESAFE_URL), which refuses the shape TypeSafe refuses, and /signal.xml is dated today, since only the last 30 days are judged.