Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
105 changes: 105 additions & 0 deletions .claude/skills/stories/SKILL.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
---
name: stories
description: Keep the right rail's stories - name the threads that run over days or weeks in agentique's feed, add new articles to them, and close the ones that went quiet. Use when running the daily stories session, or when asked to update, name or close stories.
---

# Keep the stories

A story is a theme a builder follows over days or weeks: "Codemode",
"Decision models". The right rail shows each one with a two-sentence blurb and
its articles in order. You decide what is a story, name it, and keep it up to
date. Nothing else writes stories.

A story is wider than coverage. Coverage is one event told by several outlets
(curation's `similar` and `stories`, at 0.30). A story is several events on
one theme: a launch, a repo built on it, a writer who measured it, a rival's
take a week later.

## Tools

From the `agentique` MCP server, with the curation token:

- `story_candidates(days=14)` - `open`: every open story with its newest 5
articles and `quiet_days` (since its newest article was published).
`clusters`: the last `days` of articles in no open story, grouped by
embedding, the most publishers first. Each has `articles` (with the
`summary` curation wrote), `publishers`, and `near`: open stories one of its
articles sits close to. A single article close to a story comes back as a
cluster of one with that story in `near`.
- `save_story(slug, name, blurb, urls)` - create a story, or update an open
one: set its name and blurb, add `urls` (ones already in it are skipped).
Refuses the whole call, saying why, on an unknown URL, an article already in
another open story, a bad slug, a name over 4 words, or a blurb over 2
sentences or 280 characters.
- `close_story(slug)` - close an open story. It leaves the rail and its
articles are free to join another.
- `sql_query(sql)` - read-only, for anything the tools do not answer.

The rail shows an open story once it has 3 articles from 2 publishers.

## Rounds

1. **`story_candidates()` once.** Read every open story and every cluster
before saving anything.
2. **Extend.** For each cluster with a story in `near`: add the articles that
belong to that story's theme. Pass the story's current name and blurb
unless one is now wrong (see **Keeping them stable**).
3. **Start.** For each cluster left that is a story (see **What is a story**),
create one. Before you do, check the open stories again: an article that
fits an open story goes there, even when `near` did not name it.
4. **Close.** Close every open story with `quiet_days` of 21 or more.
5. **Report.** See **Finish**.

## What is a story

All of these:

- 3 or more articles from 2 or more publishers, on one theme.
- A theme a builder can act on: a technique, a protocol, a kind of model, a
way of working. Something they could try, adopt or decide against.
- More than one event. A single launch and its retellings are coverage, not a
story, however many outlets carried it.
- Not one vendor's product line. "Claude releases" is a feed filter; "Computer
use" is a story when several makers and writers are working on it.

A cluster is a hint, not a verdict. Split one that holds two themes, and leave
out an article that is only near by wording. A cluster of two can start a story
when a third article in the window plainly belongs to it.

## Naming

- **Name:** 1-3 words, the term people use. "Codemode", not "Code execution
with MCP tools". "Decision models", not "Small models for agent routing".
If the articles use one name for it, use that name.
- **Slug:** the name in lowercase with hyphens: `codemode`,
`decision-models`. It never changes, so pick it with the name.
- **Blurb:** two plain sentences. The first says what it is, the second why it
matters now. Under 280 characters. The newsletter's plain tone without the
"I": no hype words, no em dashes, no "game-changer", no "landscape".
"The agent writes a short program against your tools instead of calling them
one at a time. Tool definitions stay out of the context; only the result
comes back."

## Keeping them stable

- Add to an open story before starting a new one. Two open stories on one
theme split the rail.
- Never rename a story without cause. Cause is: the field settled on another
name for the thing, or the name was wrong. A new article is not a cause.
- Rewrite a blurb only when "why it matters now" is no longer true.
- A closed story stays closed. If its theme comes back, start a new story
under a new slug.

## Finish

Report, short:

- created: slug, name, article count, and the line that decided it is a story
- extended: slug and how many articles added
- closed: slug and its quiet days
- clusters passed over that came close, and why
- anything a tool refused and what you did

Do not edit files or commit anything; this session reads and writes stories,
nothing else. If a tool answers "This tool needs the curation token", stop and
say so: the session has the read token, not the write token.
1 change: 1 addition & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@
- `.mcp.json` connects to the `agentique` MCP server (`backend/app/mcp/`), exposing `sql_query` (read-only prod Postgres), `web_fetch`, `web_search`.
- With the write token it also exposes `list_candidates`, `get_content`, `similar`, `stories`, `check_link`, `vocabulary`, `approve`, `reject`, `reject_many` - the curation agent's tools. They publish to prod. Don't call them outside a curation session.
- The write token also reaches `week`, `related`, `draft_issue` - the newsletter agent's tools (`.claude/skills/newsletter/SKILL.md`). `draft_issue` creates a Resend draft addressed to every subscriber; sending is done by hand in Resend.
- And `story_candidates`, `save_story`, `close_story` - the stories agent's tools (`.claude/skills/stories/SKILL.md`). They write the right rail's stories in prod.
- Don't use any of these unless the user specifically asks for them. Prefer local tools (Read, Grep, the local db) for everything else.

## Curation
Expand Down
49 changes: 49 additions & 0 deletions backend/app/alembic/versions/d1e2f3a4b5c6_add_story_tables.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
"""add story and story_article tables

Named threads for the right rail, written by the stories routine (ADR 16).

Revision ID: d1e2f3a4b5c6
Revises: c5d6e7f8a9b0
Create Date: 2026-10-10 00:00:00.000000

"""

import sqlalchemy as sa
from alembic import op

revision = "d1e2f3a4b5c6"
down_revision = "c5d6e7f8a9b0"
branch_labels = None
depends_on = None


def upgrade() -> None:
op.create_table(
"story",
sa.Column("id", sa.Integer(), nullable=False),
sa.Column("slug", sa.String(), nullable=False),
sa.Column("name", sa.String(), nullable=False),
sa.Column("blurb", sa.String(), nullable=False),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("last_article_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("closed_at", sa.DateTime(timezone=True), nullable=True),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint("slug"),
)
op.create_table(
"story_article",
sa.Column("story_id", sa.Integer(), nullable=False),
sa.Column("article_id", sa.Integer(), nullable=False),
sa.Column("added_at", sa.DateTime(timezone=True), nullable=False),
sa.ForeignKeyConstraint(["story_id"], ["story.id"], ondelete="CASCADE"),
sa.ForeignKeyConstraint(["article_id"], ["article.id"], ondelete="CASCADE"),
sa.PrimaryKeyConstraint("story_id", "article_id"),
)
op.create_index("ix_story_article_article_id", "story_article", ["article_id"])


def downgrade() -> None:
op.drop_index("ix_story_article_article_id", table_name="story_article")
op.drop_table("story_article")
op.drop_table("story")
76 changes: 75 additions & 1 deletion backend/app/catalog/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
from enum import StrEnum

from pgvector.sqlalchemy import Vector # type: ignore[import-untyped]
from sqlalchemy import JSON, Column, DateTime
from sqlalchemy import JSON, Column, DateTime, Index
from sqlmodel import Field, SQLModel

from app.platform.dates import get_datetime_utc
Expand Down Expand Up @@ -221,6 +221,54 @@ class ArticleTag(SQLModel, table=True):
tag_id: int = Field(foreign_key="tag.id", primary_key=True)


# ─── Story ─────────────────────────────────────────────────────────────────
# A named thread that runs over days or weeks. Wider than coverage (one event
# told by several outlets): an agent names and keeps stories, see ADR 16.


class Story(SQLModel, table=True):
id: int | None = Field(default=None, primary_key=True)
slug: str = Field(unique=True)
name: str
blurb: str
created_at: datetime = Field(
default_factory=get_datetime_utc,
sa_type=DateTime(timezone=True), # type: ignore
)
updated_at: datetime = Field(
default_factory=get_datetime_utc,
sa_type=DateTime(timezone=True), # type: ignore
)
# When its newest article was published. What the rail orders by and what
# "quiet" is measured from.
last_article_at: datetime | None = Field(
default=None,
sa_type=DateTime(timezone=True), # type: ignore
)
closed_at: datetime | None = Field(
default=None,
sa_type=DateTime(timezone=True), # type: ignore
)


class StoryArticle(SQLModel, table=True):
"""An article in a story. At most one open story per article, enforced in
`catalog.stories.save_story`: a closed story keeps its articles."""

__tablename__ = "story_article"
# The primary key leads with story_id; "which story holds this article" is
# asked on every save.
__table_args__ = (Index("ix_story_article_article_id", "article_id"),)
story_id: int = Field(foreign_key="story.id", primary_key=True, ondelete="CASCADE")
article_id: int = Field(
foreign_key="article.id", primary_key=True, ondelete="CASCADE"
)
added_at: datetime = Field(
default_factory=get_datetime_utc,
sa_type=DateTime(timezone=True), # type: ignore
)


# ─── Read API shapes ───────────────────────────────────────────────────────
# Consumer-facing views. `publisher` is nested (replaces the old `source` string)
# and `tags` come from the article_tag join. Pipeline provenance and bulk fields
Expand Down Expand Up @@ -303,3 +351,29 @@ class OriginCounts(SQLModel):
# names its kind.
unlabelled: int
found_via: list[FoundViaCount]


# ─── Stories (right rail) ──────────────────────────────────────────────────


class StoryArticlePublic(SQLModel):
id: int
title: str
url: str
publisher: str
published_at: datetime | None = None
score: int


class StoryPublic(SQLModel):
slug: str
name: str
blurb: str
article_count: int
publisher_count: int
first_at: datetime | None = None
last_at: datetime | None = None
# An article joined it in the last 24 hours.
grew_today: bool
# Newest first, at most `stories.STORY_ARTICLES` of `article_count`.
articles: list[StoryArticlePublic]
13 changes: 11 additions & 2 deletions backend/app/catalog/routes.py
Original file line number Diff line number Diff line change
@@ -1,24 +1,26 @@
"""Article listing, semantic search, and facet endpoints for the public feed."""
"""Article listing, semantic search, facet and story endpoints for the public feed."""

from __future__ import annotations

from datetime import datetime

from fastapi import APIRouter, HTTPException, Query

from app.catalog import facets, semantic_search
from app.catalog import facets, semantic_search, stories
from app.catalog.articles import list_articles, stats
from app.catalog.models import (
ArticleFacets,
ArticlesPublic,
Origin,
OriginCounts,
PublisherFacet,
StoryPublic,
TagFacet,
)
from app.deps import CurrentUserOptional, SessionDep

router = APIRouter(prefix="/articles", tags=["articles"])
stories_router = APIRouter(prefix="/stories", tags=["stories"])

# Reads are public and unbounded. A caller may still send a token — it only
# decides whether `liked_by_me` is filled in.
Expand Down Expand Up @@ -112,3 +114,10 @@ def search_tags(
@router.get("/stats")
def article_stats(session: SessionDep) -> dict[str, int | str | None]:
return stats(session)


@stories_router.get("/", response_model=list[StoryPublic])
def read_stories(
session: SessionDep, limit: int = Query(default=5, ge=1, le=20)
) -> list[StoryPublic]:
return stories.list_stories(session, limit)
Loading
Loading