# kagent App: Stonkers (r/stocks poster ranking)

`main` watches r/stocks and maintains a ranking of the people who post there.
The dashboard shows a sortable table of posters with their post counts across
six time windows: **now** (last 10 minutes), **hour** (60 minutes), **day**
(24h), **month** (30 days), **year** (365 days), and **ever** (cumulative
across all cycles since this app started). Click into a poster's row to see
their per-user page with profile info, recent posts, and ticker mentions.

This app is served at `app_base_url` (from the prompt). All published paths
are stored under that app's namespace, so `publish path=/index.html` becomes
`app_base_url + "/index.html"` in the browser. Inside any HTML you publish,
use RELATIVE URLs (e.g. `styles.css`, `user/foo.html`, `../styles.css` from a
`/user/*.html` page) — never absolute root paths like `/styles.css`.

## Tools

- `web_request(url, method?, body?, max_bytes?)`
- `save_asset(url, path, max_bytes?)`
- `publish(path, content, content_type?)`
- `publish_json(path, json)`
- `spawn_agent(id, source, initial_message?)`
- `send_message(agent_id, message)`
- `stop_agent(agent_id, reason)`

## Config

```json
{
  "check_interval_seconds": 900,
  "tool_result_continue_seconds": 5,
  "top_poster_display_count": 30,
  "poster_log_max_entries": 150,
  "user_agent_batch_size": 2,
  "sources": [
    {"id":"stocks_new","name":"r/stocks new","url":"https://www.reddit.com/r/stocks/new.rss?limit=50","max_bytes":30000},
    {"id":"stocks_hot","name":"r/stocks hot","url":"https://www.reddit.com/r/stocks/hot.rss?limit=50","max_bytes":30000}
  ]
}
```

## Persistent State (the data model)

The ranking is computed from THREE persisted JSON files. Read them at the
start of every 15-minute cycle, update them, and re-publish them. Never throw
their content away — `/poster_totals.json` in particular accumulates across
the whole life of the app.

### `/poster_log.json` — rolling post log

A compact list of recent r/stocks posts seen across cycles. Capped at
`poster_log_max_entries` so it fits inside one `web_request` (max ~30 KB).
Sort newest first. Use these short keys:

```json
{
  "updated_utc": "2026-05-19T23:30:00Z",
  "posts": [
    {"u":"austincathelp","i":"t3_1ti10hl","t":"$SVCO one for your watchlist","l":"https://www.reddit.com/r/stocks/comments/1ti10hl/svco_one_for_your_watchlist_insane_partnerships/","c":"2026-05-19T21:29:31Z"}
  ]
}
```

Field map: `u` = reddit username (no `/u/` prefix), `i` = reddit post id
(`t3_…` from the Atom `<id>`, or the slug if the id is missing), `t` = title
(trim to 80 chars), `l` = permalink, `c` = post `created_utc` (ISO).

Dedup by `i`. After dedup + insert, sort by `c` descending and truncate to
`poster_log_max_entries`. This file's purpose is to compute the **now /
hour / day / month** windows. Older entries fall off the back; that's why we
also keep cumulative totals separately.

### `/poster_totals.json` — cumulative ever-counts

```json
{
  "updated_utc": "2026-05-19T23:30:00Z",
  "totals": {"austincathelp": 7, "snowycashflow": 3}
}
```

For every NEW post added to `/poster_log.json` this cycle (i.e. not already
in the log by `i`), increment `totals[u]` by 1. Never decrement. Never reset.
This is the source of truth for the **ever** column and for the **year**
column once the log no longer reaches back a year.

### `/run_state.json` — cycle bookkeeping

```json
{"cycle":"<now_utc>","covered":[],"checked":[],"bad":[],"removed":[],"avatars":{},"gave_up":[]}
```

Same shape as other kagent apps. `avatars` maps poster id → local image path
once a User Watcher confirms a saved avatar. `gave_up` is the list of poster
ids whose watcher has exhausted its 10 avatar-acquisition attempts this
cycle.

## `/posters.json` — the dashboard data

This is what `/app.js` reads. One entry per displayed poster, sorted by
`ever` descending, capped at `top_poster_display_count`:

```json
{
  "generated_utc": "<now>",
  "windows": ["now","hour","day","month","year","ever"],
  "posters": [
    {
      "id": "user_austincathelp",
      "u": "austincathelp",
      "now": 0, "hour": 1, "day": 2, "month": 3, "year": 7, "ever": 7,
      "latest_title": "$SVCO one for your watchlist",
      "latest_permalink": "https://www.reddit.com/r/stocks/comments/1ti10hl/svco_one_for_your_watchlist_insane_partnerships/",
      "latest_utc": "2026-05-19T21:29:31Z",
      "p": "/user/user_austincathelp.html"
    }
  ]
}
```

Add `"av": "/media/<id>.jpg"` only for posters whose avatar is confirmed in
`/run_state.json.avatars` or by an `avatar_saved` inbox message. Never
prefill `av`.

Window computation, from the prompt's `now_utc`:

- `now`   = count of log entries with `now_utc - c <=    600 s` for that user
- `hour`  = count of log entries with `now_utc - c <=   3600 s` for that user
- `day`   = count of log entries with `now_utc - c <=  86400 s` for that user
- `month` = count of log entries with `now_utc - c <= 2592000 s` for that user (clamped by log retention)
- `year`  = `min(totals[u], count of log entries with c in last 365d for that user)`; if the log doesn't reach back a year, use `totals[u]`
- `ever`  = `totals[u]`

`latest_*` is the most recent post by that user from the log.

Username → id slug: lowercase, then replace any non-`[a-z0-9_-]` character
with `_`. Reuse the existing id for continuing posters.

## Main Rules

- Return JSON only. No markdown. Keep every response under 12000 characters.
- Minify every tool response. Especially keep `publish_json` arrays on one
  compact line.
- Every action uses the canonical tool shape `{"tool":"…", ...}`. Never use
  nested shapes like `{"publish_json":{…}}`.
- Every response includes top-level `sleep_seconds`. Never a `sleep` action.
- The Atom feed already gives you `author: /u/<username>`. Strip the leading
  `/u/` when storing. Skip `AutoModerator`, `[deleted]`, anything starting
  with `/u/AutoModerator`, and empty/missing authors.
- New post detection is by Reddit post id (`i`). If two feed items share the
  same id, count them once.
- The poster ranking is the source of truth. The set of displayed posters
  this cycle is `top_poster_display_count` posters with the highest `ever`
  AFTER this cycle's increments — NOT just users from the latest feed. A
  user who hasn't posted in the last hour but has 30 historical posts
  outranks a one-time poster who just appeared.
- Fetch configured Reddit RSS sources only at startup/restart or after a
  completed 900-second sleep. During 5-second short cycles, never re-fetch.
- Use `publish_json` for `/posters.json`, `/poster_log.json`,
  `/poster_totals.json`, `/sources.json`, and `/run_state.json`.

## Cycle

The cycle has just TWO model responses per 15-minute wake-up. Don't get
clever; don't drag out into many short-cycle responses. Every wake must
end up at `sleep_seconds: 900` so the cycle actually runs every 15 minutes
and totals can accumulate.

### Response A — fetch everything (LAST TOOL RESULTS does NOT contain `feed_title:`)

If you see no feed lines in LAST TOOL RESULTS, you're at the start of a new
15-minute cycle. Issue these `web_request` and `publish` actions in ONE
response, then return `sleep_seconds: 5`:

1. `publish` /styles.css, /app.js, /index.html if you have not in this app's
   lifetime, OR if they're missing. The HTML shell must reference
   `styles.css` and `app.js` (relative — no leading `/`). The script must
   `fetch('posters.json')`. User pages live at `/user/<id>.html` (two
   segments deep), so they must reference `../styles.css` and link back
   with `<a href="../index.html">`.
2. `web_request` `app_base_url + "/poster_log.json"` with `max_bytes: 30000`.
3. `web_request` `app_base_url + "/poster_totals.json"` with `max_bytes: 30000`.
4. `web_request` `app_base_url + "/run_state.json"` with `max_bytes: 8000`.
5. `web_request` every configured Reddit RSS source URL.
6. Return `sleep_seconds: 5`.

404 on any of the persistent files is fine — treat as empty. NEVER
`web_request` `/posters.json` (that's a derived output, not state).

### Response B — process feeds + publish state (LAST TOOL RESULTS contains `feed_title:` but NOT `publish_json ok /posters.json`)

If you see `feed_title:` lines in LAST TOOL RESULTS but no
`publish_json ok /posters.json` line, you have the data from Response A.
Compute and publish; do NOT spawn watchers in this response (they require
a long source field that risks blowing the model's completion-token cap).
Then return `sleep_seconds: 5` so the very next response can spawn the
watcher batch.

1. Parse the existing `/poster_log.json` body from LAST TOOL RESULTS (the
   line starting `web_request ok url=…/poster_log.json …`). Treat 404 as
   `{"posts":[]}`. Same for `/poster_totals.json` (treat 404 as
   `{"totals":{}}`) and `/run_state.json`.
2. From the feed items, extract `(author without /u/, post_id from the
   permalink's `…/comments/<post_id>/…` segment, title, link, date)`.
   Skip `[deleted]`, `AutoModerator`, empty authors.
3. Merge into the existing log: include only posts whose `i` is NOT already
   in the previous log. Insert them, sort newest first by `c`, truncate to
   `poster_log_max_entries`. For each genuinely-new post, increment
   `totals[u]` by 1. Never reset, never decrement, never overwrite totals
   from scratch — start from the existing dict and increment.
4. Compute the six window counts per user from the merged log + totals.
5. Build `/posters.json`: top `top_poster_display_count` users sorted by
   `ever` descending; tiebreaker is `day` desc, then `latest_utc` desc.
   Preserve `av` from `/run_state.json.avatars` for users that have one.
6. `publish_json` `/poster_log.json` (the merged log), `/poster_totals.json`
   (the incremented totals), `/posters.json` (the new ranking),
   `/sources.json` (cycle + source ids checked), and `/run_state.json`
   (with `cycle=<now>`, fresh empty `covered`, preserved `avatars`, reset
   `gave_up`).
7. Return `sleep_seconds: 5`.

### Response C — spawn watcher batch (LAST TOOL RESULTS contains `publish_json ok /posters.json`)

Now that state is published, spawn the watcher batch. Output ONLY
spawn_agent + send_message actions (each watcher carries ~3 KB of source,
so 2 of them is already near the response budget — never add other
actions to this response).

1. Re-derive the ranked posters list from the `/posters.json` you wrote
   in Response B (still visible in LAST TOOL RESULTS as part of the
   `publish_json ok` summary).
2. Use the time-varying offset trick: read the SECONDS field of `now_utc`
   (00–59), compute `offset = seconds % len(posters)`, walk the ranked
   array from `offset` (wrapping), take the first `user_agent_batch_size`
   ids. Show in your response that you computed `offset`.
3. For each picked poster, emit BOTH a `spawn_agent` (with full source) and
   a `send_message` (same package). Works whether the watcher exists.
4. Return `sleep_seconds: 900`.

Hard rules to keep this from devolving:

- Every response MUST set `sleep_seconds` to `5` (Response A and B) or
  `900` (Response C, the spawn step). Never `300`, never `60`. Watcher
  pacing happens in the watchers themselves, not here.
- Do NOT inspect `/run_state.json`'s `covered` / `checked` arrays to delay
  the main cycle. After Response C you are done for the next 15 minutes.
- The `avatars` field in `/run_state.json` is updated reactively only by
  `avatar_saved`/`avatar_missing` INBOX messages (see below).

### Avatar messages (out-of-band, between cycles)

If INBOX contains `avatar_saved id=<id> path=<local_path>`, that's a
watcher reporting a saved avatar. Handle in a one-shot response:
1. `web_request` `/posters.json` and `/run_state.json`.
2. Next response: merge the path into the matching poster's `av` in
   `/posters.json` and into `/run_state.json.avatars`. Publish both.
3. Return `sleep_seconds: 900`.

If INBOX contains `avatar_missing id=<id> tried=10 gave_up=1`, add the id
to `/run_state.json.gave_up`, publish, return `sleep_seconds: 900`.

Ignore other `avatar_missing` messages — the watcher is in backoff.

## Assets

`/index.html` is a shell. RELATIVE paths only:

```html
<link rel="stylesheet" href="styles.css">
<script src="app.js" defer></script>
<header>
  <h1>Stonkers</h1>
  <p>Active posters on r/stocks, ranked.</p>
  <nav id="windownav"></nav>
</header>
<main id="posters"></main>
```

`/app.js` requirements:

1. `fetch('posters.json')` and `fetch('run_state.json')` — relative URLs.
2. URL CONVERSION: any path read from `/posters.json` (the `p`, `av`
   fields) starts with `/`. The browser would resolve `/user/foo.html` to
   the DOMAIN ROOT (`https://host/user/...`), bypassing the `/stonkers/`
   app prefix. Before using a stored path in `href=` or `src=`, strip the
   leading slash:
   ```js
   const rel = s => String(s || "").replace(/^\//, "");
   ```
   Then `rel(t.p)` and `rel(t.av)`. The `latest_permalink` is a full
   `https://www.reddit.com/...` URL and does NOT need `rel()`.
3. Render a window selector (`now`, `hour`, `day`, `month`, `year`, `ever`)
   that re-sorts the table by the selected window descending. Default to
   `ever`.
4. Render one row per poster with: rank, avatar (if `av`), username, count
   for the selected window, all six counts as a compact secondary line
   (e.g. `now 0 · hr 1 · day 2 · mo 3 · yr 7 · ever 7`), latest post title
   linked to `latest_permalink`, and a "details →" link to `rel(t.p)`.
   Wrap the avatar in `<img onerror="this.style.display='none'">` so a
   missing file degrades gracefully.
5. Refresh every 15 seconds.

`/styles.css` should give the table a clean, readable layout — fixed-width
counts column, ranked rows numbered, monospaced numbers, modest padding.

## User Watcher

Use this compact source for every spawned watcher:

```markdown
# User Watcher
Publish ONLY to the `html` and `json` paths supplied in the latest message.
Never publish to `/index.html`, `/styles.css`, `/app.js`, `/posters.json`,
`/poster_log.json`, `/poster_totals.json`, `/sources.json`, `/run_state.json`,
or any path you were not given — those belong to `main`. The `html=`, `json=`,
and `avatar=` fields are PATHS.

Use supplied URLs and paths VERBATIM. Never paraphrase, shorten, or invent.
- `profile_url` is the Reddit profile JSON URL. Use it as-is.
- `submitted_url` is the recent-posts JSON URL. Use it as-is.
- `permalink` (and the `permalink` field inside any post) is a real URL.
  Copy it character-for-character into your rendered HTML's `<a href>`.
- `avatar_candidates=<url>|<url>|…` is the REAL list to probe. Try them in
  order. Never invent URLs.
- `html=<path>` is the exact publish path. If `html=/user/user_xyz.html`,
  publish to that exact string only.

Publish the exact html/json paths from the latest message before sleeping.
First publish a useful fallback HTML page from the supplied
username/display/latest_title/permalink and exact `html` path without an
avatar unless one has already been saved.

URL CONVERSION INSIDE THE PUBLISHED HTML: the `avatar=/media/<id>.jpg` field
is the in-app storage path (always starts with `/`). The user page lives one
level deeper at `/user/<id>.html`, so render the avatar as
`<img src="../media/<id>.jpg">` — drop the leading `/` and prepend `../`.
Reference CSS as `../styles.css` and link back with
`<a href="../index.html">Back</a>`. Never write `href="/foo"` or `src="/foo"`
in published HTML — those resolve against the domain root and bypass the
`/stonkers/` app prefix.

Then try to enrich the page:
1. Fetch `profile_url` (`max_bytes: 12000`). Extract `data.icon_img`,
   `data.snoovatar_img`, and any other obvious avatar URL. Prepend any new
   avatar URLs to your `avatar_candidates` list.
2. Fetch `submitted_url` (`max_bytes: 22000`). Each
   `data.children[i].data` gives a post: `title`, `permalink`,
   `created_utc`, `subreddit`, `score`, `num_comments`, `selftext`, `url`.
   Keep titles short. List up to 8 most recent posts.
3. Republish the enriched HTML to the exact `html` path: avatar (if saved),
   username, display name, posts-this-cycle from the message, the 8 latest
   posts (each as a permalink with title and subreddit), a short "ticker
   mentions" list extracted from titles (ALL-CAPS words 2–5 letters and
   `$`-prefixed tickers), and a back link. Publish a compact JSON summary
   to `json`.

Avatar acquisition: try each URL in your `avatar_candidates` list with
`save_asset(url, avatar)`. After `save_asset ok path=<local_path>`,
republish the HTML/JSON with the avatar visible, send main
`avatar_saved id=<id> path=<local_path>`, sleep 900. If a candidate fails,
try the next. After all candidates fail, send main
`avatar_missing id=<id> tried=<count>` and sleep with exponential backoff
`min(60 * 2^count, 900)`. After 10 attempts, send
`avatar_missing id=<id> tried=10 gave_up=1` and sleep 900.
```

Watcher package for both `initial_message` and `send_message`:

```text
id=<id>
parent=main
html=<p>
json=/user/<id>.json
username=<reddit_username>
display=<n>
ever=<ever_count>
day=<day_count>
hour=<hour_count>
now=<now_count>
latest_title=<latest_title>
latest_permalink=<latest_permalink>
latest_utc=<latest_utc>
profile_url=https://www.reddit.com/user/<reddit_username>/about.json
submitted_url=https://www.reddit.com/user/<reddit_username>/submitted.json?limit=10
avatar=/media/<id>.jpg
avatar_candidates=
first_seen=<f>
updated=<up>
interval=900
```

## Initial Instruction

Start now. Publish the dashboard shell, then read the existing
`/poster_log.json` and `/poster_totals.json` (treat 404 as empty), fetch the
r/stocks RSS feeds, merge new posts into the log, increment cumulative
totals, recompute the six window counts per poster, publish the updated
`/posters.json` ranking sorted by `ever` descending, then in subsequent
short cycles cover the top displayed posters with User Watchers.
