# X Hot Topic Digest

A scheduled Eve agent that scans a configured set of X (Twitter) profiles every day, surfaces hot topics from their recent posts, researches each topic with the [Parallel](https://parallel.ai/) web search API, and delivers an HTML digest by email through [Resend](https://resend.com).

- Install: `npx shadcn@latest add @evex/x-hot-topic-digest`
- Category: research
- Author: [TommyBez](https://www.evex.sh/authors/TommyBez)
- Updated: 2026-07-04
- Dependencies: ai@^7.0.38, eve@^0.31.3, parallel-web@^1.1.0, resend@^6.14.0, zod@4.3.6
- Web page: https://www.evex.sh/agents/x-hot-topic-digest
- This document: https://www.evex.sh/agents/x-hot-topic-digest.md

## Overview

X Hot Topic Digest is a scheduled eve agent that watches a list of X (Twitter) profiles you configure and turns their recent posts into a researched daily briefing. It runs unattended on a cron schedule (X_HOT_TOPIC_DAILY_CRON, default 0 8 * * * UTC), reads only public posts through the X API v2 with an app-only bearer token, and never posts anything back to X.

Each run scans up to 20 recent posts per handle inside a configurable lookback window (default 24 hours), clusters them into at most 5 hot topics by recurrence and engagement, and researches every topic with the Parallel web search API to attach ranked sources with titles, URLs, and excerpts. The result is a single accessible HTML email delivered through Resend to your configured recipients.

You interact with it through configuration and your inbox: set the handles, schedule, and recipients in environment variables, and the digest arrives by email. The agent gates every send: it previews the email first and only sends with an explicit confirmation flag plus an idempotency key, so a replayed run never delivers the same digest twice.

## How it works

1. On the daily-hot-topic-digest schedule (cron from X_HOT_TOPIC_DAILY_CRON, default 08:00 UTC), the agent loads the bundled email-best-practices skill before drafting anything.
2. It calls the scan_x_profiles tool, which resolves each handle in X_HOT_TOPIC_HANDLES via X API v2, fetches recent posts excluding retweets, and filters them to the X_HOT_TOPIC_LOOKBACK_HOURS window along with like, reply, repost, and impression counts.
3. From those posts it surfaces up to X_HOT_TOPIC_MAX_TOPICS recurring themes, announcements, or high-engagement signals, clustering near-duplicate posts into a single topic.
4. For each topic it calls research_hot_topics, which sends 2 to 3 focused keyword queries to the Parallel Search API in the configured mode (turbo, basic, or advanced) and returns ranked results with URLs, titles, publish dates, and excerpts.
5. It composes one HTML email with a per-topic takeaway, the originating X posts with links, and the research sources, then calls preview_digest_email to review the exact sender, recipients, subject, and HTML before anything is sent.
6. Finally it calls send_digest_email with confirmSend set to true and a date-derived idempotency key that is forwarded to Resend as the Idempotency-Key header; four bundled evals verify the digest flow, send confirmation, failed-send reporting, and that missing configuration blocks delivery.

## Use cases

### Daily competitor and ecosystem watch

Track the X accounts of competitors, partners, and platform vendors and get one morning email that names what they announced, how much engagement it drew, and what independent web sources say about it.

### Founder or investor market signal briefing

Watch a curated list of founders, VCs, and analysts and receive up to five clustered topics per day, each backed by Parallel research sources, instead of scrolling timelines yourself.

### Developer relations trend monitoring

Follow framework maintainers and developer advocates to catch launches, RFC debates, and breaking-change chatter early, with links back to the original posts and supporting articles for your team.

### Team newsletter without the manual curation

Point X_HOT_TOPIC_DIGEST_TO at a team alias and let the agent produce an accessible HTML digest daily; the 24-hour lookback window keeps topics from repeating day over day.

## Requirements

- `X_BEARER_TOKEN`: App-only bearer token from the X Developer Console. Used to resolve handles and read public posts via X API v2; the agent stops with an authRequired error if it is missing.
- `X_HOT_TOPIC_HANDLES`: Comma-separated X handles to scan, with or without the @ prefix, for example vercel,parallel_ai,anthropicai. With no handles configured the run reports the missing configuration instead of inventing profiles.
- `PARALLEL_API_KEY`: API key for the Parallel Search API, created at platform.parallel.ai. Powers the research_hot_topics tool that attaches ranked web sources to each topic.
- `RESEND_API_KEY`: Resend API key used by send_digest_email to deliver the HTML digest. The idempotency key is passed to Resend so retried sends are deduplicated.
- `X_HOT_TOPIC_DIGEST_FROM`: Sender address for the digest; it must be verified in Resend or delivery fails. Recipients and sender cannot be overridden through tool input.
- `X_HOT_TOPIC_DIGEST_TO`: Comma-separated recipient addresses. Optional tuning variables include X_HOT_TOPIC_DAILY_CRON, X_HOT_TOPIC_LOOKBACK_HOURS, X_HOT_TOPIC_MAX_TWEETS_PER_PROFILE, X_HOT_TOPIC_MAX_TOPICS, X_HOT_TOPIC_SEARCH_MAX_RESULTS, X_HOT_TOPIC_SEARCH_MODE, and X_HOT_TOPIC_DIGEST_SUBJECT.

## FAQ

### How do I install and run it?

Install with npx shadcn@latest add @evex/x-hot-topic-digest, copy .env.example into your eve app environment, fill in the X, Parallel, and Resend credentials plus handles and recipients, then trigger the schedule in dev with a POST to /eve/v1/dev/schedules/daily-hot-topic-digest.

### Can it accidentally send duplicate or unwanted emails?

No. send_digest_email refuses to send unless confirmSend is true, and every send requires a stable idempotency key derived from the digest date. The key is cached in the tool and forwarded to Resend as the Idempotency-Key header, so a replayed eve step returns the recorded result instead of sending again.

### Which model does the agent use?

The agent is defined with openai/gpt-5.4-mini in agent/agent.ts. You can change the model there after installing; the tools, schedule, and instructions are plain TypeScript and Markdown files you own in your project.

### How do I control how much it scans and researches?

Tune the environment variables: lookback window defaults to 24 hours, up to 20 posts per profile, at most 5 topics per run, and 5 Parallel results per topic. X_HOT_TOPIC_SEARCH_MODE switches Parallel between turbo, basic, and advanced search.

### Does it post to X or need write access?

No. The agent only reads public posts with app-only bearer auth and explicitly excludes retweets. Its guardrails forbid posting to X, and every citation in the digest must come from a tool result, so URLs, excerpts, and post ids are never fabricated.

## Files installed

- `agent/agent.ts`
- `agent/instructions.md`
- `agent/lib/hot-topic-config.ts`
- `agent/schedules/daily-hot-topic-digest.ts`
- `agent/skills/email-best-practices/references/accessibility.md`
- `agent/skills/email-best-practices/references/sending-reliability.md`
- `agent/skills/email-best-practices/SKILL.md`
- `agent/tools/preview_digest_email.ts`
- `agent/tools/research_hot_topics.ts`
- `agent/tools/scan_x_profiles.ts`
- `agent/tools/send_digest_email.ts`
- `evals/evals.config.ts`
- `evals/failed-send-not-delivered.eval.ts`
- `evals/hot-topic-digest.eval.ts`
- `evals/missing-config-does-not-send.eval.ts`
- `evals/send-confirmation.eval.ts`
- `agent/README.md`
- `.env.example`

## File contents

### `agent/agent.ts`

```ts
import { defineAgent } from "eve";

export default defineAgent({
  model: "openai/gpt-5.4-mini",
});

```

### `agent/instructions.md`

```md
# Mission
Produce a daily digest of hot topics from a watched set of X (Twitter) profiles, researched with the Parallel web search API, and delivered by email through Resend.

# Workflow
1. Load the email-best-practices skill before drafting or sending the digest email.
2. Use scan_x_profiles to pull recent posts from every configured handle, scoped to the last `X_HOT_TOPIC_LOOKBACK_HOURS` (default 24). If no handles are configured, stop and report the missing configuration instead of inventing profiles. Only treat posts inside the lookback window as hot-topic candidates, so the digest does not repeat the same topics day over day.
3. From the returned posts, surface up to `X_HOT_TOPIC_MAX_TOPICS` hot topics. A hot topic is a recurring theme, announcement, launch, debate, or signal that appears across posts or that carries outsized engagement for a profile. Cluster near-duplicates into a single topic.
4. For each hot topic, use research_hot_topics with 2-3 focused keyword queries to gather ranked web sources with provenance. Skip research for topics that are too vague to query.
5. Compose a single digest email in HTML following the email-best-practices accessibility rules:
   - a short intro naming the date and watched handles
   - one section per hot topic with: a one-line takeaway, the originating X posts (handle, snippet, link `https://x.com/<handle>/status/<id>`), and the Parallel research sources (title, url, short excerpt)
   - a closing note distinguishing observed X signal from web research
6. Always call preview_digest_email first to review the exact recipients, sender, subject, and HTML. Recipients and sender come from `X_HOT_TOPIC_DIGEST_TO` / `X_HOT_TOPIC_DIGEST_FROM` and cannot be overridden through tool input — never try to pass `to` or `from` to the send tool.
7. To send for real, call send_digest_email with `confirmSend: true` and a stable `idempotencyKey` derived from the digest date (for example `x-hot-topic-digest-YYYY-MM-DD`). Never call send_digest_email without an idempotencyKey. The idempotency key makes a replayed step safe, so reuse the same key if the step is retried. If send_digest_email returns `sent: false` with an `error`, report the error and do not treat the digest as delivered.

# Output contract
Return:
- the list of hot topics with origin posts and research sources
- the email preview from preview_digest_email
- the send result from send_digest_email when it was called, including the idempotencyKey
- any missing configuration that blocked a step

# Guardrails
- Do not post on X. This agent only reads public posts and sends email.
- Do not fabricate URLs, excerpts, or post ids. Every citation must come from a tool result.
- If a tool reports `authRequired` or `notConfigured`, stop and report it instead of proceeding.

```

### `agent/lib/hot-topic-config.ts`

```ts
export type HotTopicConfig = {
  readonly handles: readonly string[];
  readonly dailyCron: string;
  readonly lookbackHours: number;
  readonly maxTweetsPerProfile: number;
  readonly maxHotTopics: number;
  readonly searchMaxResults: number;
  readonly searchMode: "turbo" | "basic" | "advanced";
  readonly digest: {
    readonly from?: string;
    readonly to: readonly string[];
    readonly subject: string;
  };
};

const DEFAULT_MAX_TWEETS_PER_PROFILE = 20;
const DEFAULT_MAX_HOT_TOPICS = 5;
const DEFAULT_SEARCH_MAX_RESULTS = 5;
const DEFAULT_SEARCH_MODE = "basic";
const DEFAULT_DAILY_CRON = "0 8 * * *";
const DEFAULT_LOOKBACK_HOURS = 24;
const DEFAULT_SUBJECT = "X Hot Topic Digest";

const compactCsv = (value: string | undefined): string[] =>
  (value ?? "")
    .split(",")
    .map((item) => item.trim())
    .filter(Boolean);

const optional = (value: string | undefined): string | undefined => {
  const trimmed = value?.trim();
  return trimmed ? trimmed : undefined;
};

const parsePositiveInteger = (value: string | undefined, fallback: number): number => {
  const parsed = Number.parseInt(value ?? "", 10);
  return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
};

const parseSearchMode = (value: string | undefined): "turbo" | "basic" | "advanced" => {
  const trimmed = value?.trim().toLowerCase();
  if (trimmed === "turbo" || trimmed === "basic" || trimmed === "advanced") {
    return trimmed;
  }
  return DEFAULT_SEARCH_MODE;
};

const parseEmailList = (value: string | undefined): string[] => compactCsv(value);

const toRfc3339Utc = (date: Date): string =>
  date.toISOString().replace(/\.\d{3}Z$/, "Z");

export const getLookbackStartTime = (now: Date = new Date()): string =>
  toRfc3339Utc(new Date(now.getTime() - hotTopicConfig.lookbackHours * 60 * 60 * 1000));

export const hotTopicConfig = {
  handles: compactCsv(process.env.X_HOT_TOPIC_HANDLES),
  dailyCron: optional(process.env.X_HOT_TOPIC_DAILY_CRON) ?? DEFAULT_DAILY_CRON,
  lookbackHours: parsePositiveInteger(
    process.env.X_HOT_TOPIC_LOOKBACK_HOURS,
    DEFAULT_LOOKBACK_HOURS,
  ),
  maxTweetsPerProfile: parsePositiveInteger(
    process.env.X_HOT_TOPIC_MAX_TWEETS_PER_PROFILE,
    DEFAULT_MAX_TWEETS_PER_PROFILE,
  ),
  maxHotTopics: parsePositiveInteger(
    process.env.X_HOT_TOPIC_MAX_TOPICS,
    DEFAULT_MAX_HOT_TOPICS,
  ),
  searchMaxResults: parsePositiveInteger(
    process.env.X_HOT_TOPIC_SEARCH_MAX_RESULTS,
    DEFAULT_SEARCH_MAX_RESULTS,
  ),
  searchMode: parseSearchMode(process.env.X_HOT_TOPIC_SEARCH_MODE),
  digest: {
    from: optional(process.env.X_HOT_TOPIC_DIGEST_FROM),
    to: parseEmailList(process.env.X_HOT_TOPIC_DIGEST_TO),
    subject: optional(process.env.X_HOT_TOPIC_DIGEST_SUBJECT) ?? DEFAULT_SUBJECT,
  },
} satisfies HotTopicConfig;

```

### `agent/schedules/daily-hot-topic-digest.ts`

```ts
import { defineSchedule } from "eve/schedules";

import { hotTopicConfig } from "../lib/hot-topic-config.js";

export default defineSchedule({
  cron: hotTopicConfig.dailyCron,
  markdown: `Run the daily X hot topic digest.

1. Use scan_x_profiles to scan every handle configured in X_HOT_TOPIC_HANDLES, scoped to the last ${hotTopicConfig.lookbackHours} hours (X_HOT_TOPIC_LOOKBACK_HOURS). Only treat posts inside the lookback window as hot-topic candidates.
2. Surface up to ${hotTopicConfig.maxHotTopics} hot topics from those posts.
3. For each topic, call research_hot_topics with focused keyword queries.
4. Compose an HTML digest and call preview_digest_email to review the exact recipients, sender, subject, and HTML.
5. To send for real, call send_digest_email with confirmSend=true and a stable idempotencyKey derived from today's date (for example x-hot-topic-digest-YYYY-MM-DD). Reuse the same idempotencyKey if the step is retried so a replayed send never duplicates the email.

If any required environment variable is missing (X_BEARER_TOKEN, PARALLEL_API_KEY, RESEND_API_KEY, X_HOT_TOPIC_DIGEST_FROM, X_HOT_TOPIC_DIGEST_TO), stop and report the missing configuration. Do not invent handles, topics, sources, or recipients. Never call send_digest_email without confirmSend=true and an idempotencyKey.`,
});

```

### `agent/skills/email-best-practices/references/accessibility.md`

````md
# Accessibility

The digest must be readable by screen readers, dark-mode clients, translation tools, and
AI clients — not just sighted readers on a default inbox. Apply these rules every time
the digest HTML is composed.

## Rules

### Set `lang` and `dir` on `<html>` and on `<body>`'s direct children

Several email clients strip these attributes from `<html>`, so duplicate them on the
body's direct children.

```html
<html lang="en" dir="ltr">
  <head>
    <title>X Hot Topic Digest — 2026-06-26</title>
  </head>
  <body>
    <div lang="en" dir="ltr">
      <!-- digest content -->
    </div>
  </body>
</html>
```

- `lang`: a BCP 47 language tag (`en`, `it`, `ja`, `ar`).
- `dir`: `ltr`, `rtl`, or `auto`.

For multi-locale digests, pass the locale through; do not hardcode `en`.

### Mark layout tables as presentational

Any `<table>` used for layout must have `role="presentation"` (or `role="none"`).
Otherwise screen readers announce "table, row 1 of N" for every layout row.

```html
<table role="presentation" cellpadding="0" cellspacing="0" border="0">
  <tr>
    <td>...</td>
  </tr>
</table>
```

### Use a single `<h1>` and nest headings in order

One `<h1>` names the digest ("X Hot Topic Digest — 2026-06-26"). Each hot topic is an
`<h2>`; sub-sections (origin posts, research sources) are `<h3>`. Never skip levels or
fake a heading with bold `<p>`.

```html
<h1>X Hot Topic Digest — 2026-06-26</h1>
  <h2>AI SDK 5 ships with agent loops</h2>
    <h3>Origin posts</h3>
    <h3>Research sources</h3>
```

### Every link must have discernible text

Every `<a>` must contain text a screen reader can announce. X post links should use the
handle and a short snippet; Parallel source links should use the source title.

```html
<!-- Wrong -->
<a href="https://x.com/vercel/status/1700000000000000001">click here</a>
<a href="https://x.com/vercel/status/1700000000000000001">
  https://x.com/vercel/status/1700000000000000001
</a>

<!-- Right -->
<a href="https://x.com/vercel/status/1700000000000000001">
  vercel on X: We just shipped AI SDK 5 with native agent loops
</a>
```

Never use "click here", "learn more", bare URLs, or a linked image with empty alt.

### Meaningful alt text, and `alt=""` for decorative images

- Meaningful images (charts, screenshots): describe purpose and key details in context.
- Decorative images (spacers, dividers): use an explicit `alt=""` so screen readers skip
  them. Never omit the attribute entirely.
- A linked image is never decorative — its `alt` must describe the action.

### Include a `<title>` tag

Many clients and assistive technologies read `<title>` before anything else. Treat it
like the subject line, not the brand name:

```html
<title>X Hot Topic Digest — 2026-06-26</title>
```

### Color contrast and dark mode

- Body text and links: 4.5:1 minimum against the background (WCAG AA).
- Large text (≥18pt, or ≥14pt bold): 3:1 minimum.
- Never rely on color alone to convey meaning.
- Outlook and Apple Mail force dark mode; preview the digest in dark mode before sending.

## Priority order

When you cannot fix everything, fix in this order:

1. Missing or misused `alt` on images.
2. `lang`/`dir` on `<html>` and body children, `role="presentation"` on layout tables,
   links without discernible text, missing `<title>`, color contrast.
3. Non-descriptive link text ("click here").
4. Missing `<h1>`.

## Authoring checklist

- [ ] `<html>` has `lang` and `dir`; direct children of `<body>` also have `lang` and `dir`
- [ ] `<title>` is set and specific to this digest
- [ ] Layout `<table>` elements have `role="presentation"`
- [ ] One `<h1>`; `<h2>`/`<h3>` nested in order
- [ ] Every `<a>` has discernible text that describes its destination
- [ ] No "click here", bare URLs, or linked images with empty alt
- [ ] Meaningful images have descriptive `alt`; decorative images have explicit `alt=""`
- [ ] Body text passes 4.5:1 contrast and stays readable in dark mode
- [ ] A plain-text alternative is sent alongside the HTML

## Related

- [Sending Reliability](./sending-reliability.md) — idempotent sends and error handling

````

### `agent/skills/email-best-practices/references/sending-reliability.md`

````md
# Sending Reliability

Ensuring an email is sent exactly once and that failures are handled gracefully.

## Idempotency

Prevent duplicate emails when retrying failed requests.

### The problem

Network issues, timeouts, or server errors can leave you uncertain whether an email was
sent. Retrying without idempotency risks sending duplicates.

### Solution: idempotency keys

Send a unique key with each request. If the same key is sent again, the provider returns
the original response instead of sending another email. Resend accepts this as the
`Idempotency-Key` header.

```typescript
// Deterministic key based on the business event
const idempotencyKey = `password-reset-${userId}-${resetRequestId}`;

await resend.emails.send(
  {
    from: 'noreply@example.com',
    to: user.email,
    subject: 'Reset your password',
    html: emailHtml,
  },
  { idempotencyKey },
);
```

### Key generation strategies

| Strategy | Example | Use when |
|----------|---------|----------|
| Event-based (recommended) | `order-confirm-${orderId}` | One email per event |
| Request-scoped | `reset-${userId}-${resetRequestId}` | Retries within same request |
| UUID | `crypto.randomUUID()` | No natural key (generate once, reuse on retry) |

**Best practice:** use deterministic keys based on the business event. If you retry the
same logical send, the same key must be regenerated. Avoid `Date.now()` or random values
generated fresh on each attempt.

**Key expiration:** idempotency keys are typically cached for 24 hours. Retries within
this window return the original response. After expiration, the same key triggers a new
send — so complete retry logic well within 24 hours.

## Result shape: check `error`, don't rely on throws

Email APIs such as Resend resolve `send` with `{ data, error }` rather than throwing on
failure. An unverified sender, invalid recipient, rate limit, or validation error comes
back as an `error` result, not an exception.

```typescript
const { data, error } = await resend.emails.send(emailPayload, { idempotencyKey });

if (error) {
  // Not delivered. Do not cache as success; the same key can be retried.
  return { sent: false, error: { message: error.message, name: error.name } };
}

// Only successful sends are safe to short-circuit on replay.
return { sent: true, messageId: data?.id };
```

A failed send must not be cached as a success. Only successful sends should be
short-circuited on replay; failures need to be retried with the same idempotency key.

## Retry logic

Handle transient failures with exponential backoff.

| Error type | Retry? | Notes |
|------------|--------|-------|
| 5xx (server error) | Yes | Transient, likely to resolve |
| 429 (rate limit) | Yes | Wait for the rate limit window |
| 4xx (client error) | No | Fix the request first |
| Network timeout | Yes | Transient |
| DNS failure | Yes | May be transient |

```typescript
async function sendWithRetry(emailData, maxRetries = 3) {
  for (let attempt = 0; attempt < maxRetries; attempt++) {
    const { data, error } = await resend.emails.send(emailData);
    if (!error) return data;

    if (isRetryable(error) || attempt < maxRetries - 1) {
      const delay = Math.min(1000 * 2 ** attempt, 30000);
      await sleep(delay + Math.random() * 1000); // jitter
      continue;
    }
    throw error;
  }
}
```

Backoff schedule: 1s → 2s → 4s → 8s, with jitter to prevent thundering herd.

## Timeouts

Set appropriate timeouts to avoid hanging requests. 10–30 seconds is reasonable for
email API calls.

## Related

- [Accessibility](./accessibility.md) — composing the HTML body

````

### `agent/skills/email-best-practices/SKILL.md`

```md
---
name: email-best-practices
description: Send transactional email through Resend with exactly-once delivery, deliverability, and accessible HTML.
---

Guidance for building deliverable, accessible, exactly-once transactional emails sent
through an email API such as Resend. Apply the rules below whenever an email is being
drafted or sent.

## Sending exactly once

Network issues, timeouts, and server errors can leave a send's outcome uncertain.
Retrying without protection duplicates the email. Use an idempotency key: a stable value
derived from the business event, sent with the request, so a retried send with the same
key returns the original outcome instead of issuing a second email.

See [sending-reliability](./references/sending-reliability.md) for the idempotency and
retry model, including key derivation, provider cache windows, and Resend `{ data, error }`
handling.

## Deliverability

The sender domain must be authenticated (SPF/DKIM/DMARC) and the sender address verified
by the provider. Unverified senders are the most common cause of bounces and spam
filtering — Gmail and Yahoo reject unauthenticated email outright.

## Composing accessible HTML

Email must be readable by screen readers, dark-mode clients, translation tools, and AI
clients, not just sighted readers on a default inbox.

- Set `lang` and `dir` on `<html>` and on `<body>`'s direct children (some clients strip
  them from `<html>`).
- Include a `<title>` that names the specific email, not just the brand.
- Use one `<h1>` and nest `<h2>`/`<h3>` in order. Never skip levels or fake headings with
  bold text.
- Layout tables must carry `role="presentation"`.
- Every link must have discernible text that describes its destination — never "click
  here", bare URLs, or linked images with empty alt.
- Meaningful images need descriptive `alt`; decorative images need an explicit `alt=""`.
- Body text must pass 4.5:1 contrast and stay readable in dark mode.
- Send a plain-text alternative alongside the HTML.

See [accessibility](./references/accessibility.md) for the full checklist and priority
order.

```

### `agent/tools/preview_digest_email.ts`

```ts
import { defineTool } from "eve/tools";
import { z } from "zod";

import { hotTopicConfig } from "../lib/hot-topic-config.js";

export default defineTool({
  description:
    "Preview the X hot topic digest email without sending it. Resolves recipients and sender from configuration and returns the exact payload that send_digest_email would send. Recipients and sender come from configuration and cannot be overridden via input.",
  inputSchema: z.object({
    subject: z.string().min(1).optional(),
    html: z.string().min(1),
  }),
  async execute({ subject, html }) {
    const resolvedFrom = hotTopicConfig.digest.from;
    if (!resolvedFrom) {
      return { notConfigured: true, missingEnv: "X_HOT_TOPIC_DIGEST_FROM" };
    }

    const resolvedTo = hotTopicConfig.digest.to;
    if (resolvedTo.length === 0) {
      return { notConfigured: true, missingEnv: "X_HOT_TOPIC_DIGEST_TO" };
    }

    return {
      dryRun: true,
      from: resolvedFrom,
      to: resolvedTo,
      subject: subject ?? hotTopicConfig.digest.subject,
      htmlPreview: html.slice(0, 500),
      htmlLength: html.length,
    };
  },
});

```

### `agent/tools/research_hot_topics.ts`

```ts
import Parallel from "parallel-web";
import { defineTool } from "eve/tools";
import { z } from "zod";

import { hotTopicConfig } from "../lib/hot-topic-config.js";

export default defineTool({
  description:
    "Research a hot topic with the Parallel web search API and return ranked excerpts with provenance.",
  inputSchema: z.object({
    topic: z.string().min(1).describe("The hot topic to research, in natural language."),
    searchQueries: z
      .array(z.string().min(1))
      .min(1)
      .max(5)
      .describe("2-3 concise keyword queries (3-6 words each) to focus the search."),
    maxResults: z
      .number()
      .int()
      .min(1)
      .max(10)
      .optional()
      .describe("Upper bound on returned results. Defaults to the agent config."),
  }),
  async execute({ topic, searchQueries, maxResults }) {
    const apiKey = process.env.PARALLEL_API_KEY;
    if (!apiKey) {
      return { authRequired: true, missingEnv: "PARALLEL_API_KEY", topic };
    }

    const client = new Parallel({ apiKey });
    const { results } = await client.search({
      objective: `Research the following hot topic surfaced from X: ${topic}`,
      search_queries: searchQueries,
      mode: hotTopicConfig.searchMode,
      advanced_settings: {
        max_results: maxResults ?? hotTopicConfig.searchMaxResults,
      },
    });

    return {
      topic,
      resultCount: results.length,
      results: results.map((entry) => ({
        url: entry.url,
        title: entry.title ?? null,
        publishDate: entry.publish_date ?? null,
        excerpts: entry.excerpts,
      })),
    };
  },
});

```

### `agent/tools/scan_x_profiles.ts`

```ts
import { defineTool } from "eve/tools";
import { z } from "zod";

import { getLookbackStartTime, hotTopicConfig } from "../lib/hot-topic-config.js";

const X_API_BASE = "https://api.x.com/2";
const TWEET_FIELDS = "created_at,public_metrics,entities,lang";
const EXCLUDE = "retweets";
const MIN_MAX_RESULTS = 5;

type XPublicMetrics = {
  readonly impression_count?: number;
  readonly like_count?: number;
  readonly reply_count?: number;
  readonly retweet_count?: number;
  readonly quote_count?: number;
  readonly bookmark_count?: number;
};

type XTweet = {
  readonly id: string;
  readonly text: string;
  readonly created_at?: string;
  readonly lang?: string;
  readonly public_metrics?: XPublicMetrics;
};

type XUserLookupResponse = {
  readonly data?: { readonly id: string; readonly name: string; readonly username: string };
};

type XTweetsResponse = {
  readonly data?: readonly XTweet[];
  readonly meta?: { readonly result_count?: number; readonly newest_id?: string };
};

const userIdCache = new Map<string, string>();

async function xFetch<T>(path: string, searchParams?: URLSearchParams): Promise<T> {
  const bearer = process.env.X_BEARER_TOKEN;
  if (!bearer) {
    throw new Error("Missing X_BEARER_TOKEN environment variable.");
  }

  const url = searchParams ? `${path}?${searchParams.toString()}` : path;
  const response = await fetch(`${X_API_BASE}${url}`, {
    headers: { Authorization: `Bearer ${bearer}` },
  });

  if (!response.ok) {
    const body = await response.text();
    throw new Error(`X API ${response.status} for ${path}: ${body.slice(0, 500)}`);
  }

  return (await response.json()) as T;
}

async function resolveUserId(handle: string): Promise<string> {
  const normalized = handle.replace(/^@/, "");
  const cached = userIdCache.get(normalized);
  if (cached) return cached;

  const lookup = await xFetch<XUserLookupResponse>(
    `/users/by/username/${encodeURIComponent(normalized)}`,
  );
  if (!lookup.data?.id) {
    throw new Error(`Could not resolve X user id for @${normalized}.`);
  }

  userIdCache.set(normalized, lookup.data.id);
  return lookup.data.id;
}

async function fetchUserTweets(handle: string, startTime: string): Promise<readonly XTweet[]> {
  const userId = await resolveUserId(handle);
  const maxResults = Math.max(hotTopicConfig.maxTweetsPerProfile, MIN_MAX_RESULTS);
  const params = new URLSearchParams({
    max_results: maxResults.toString(),
    "tweet.fields": TWEET_FIELDS,
    exclude: EXCLUDE,
    start_time: startTime,
  });

  const payload = await xFetch<XTweetsResponse>(`/users/${userId}/tweets`, params);
  return payload.data ?? [];
}

function withinLookback(tweet: XTweet, startTimeMs: number): boolean {
  if (!tweet.created_at) return false;
  const createdAt = Date.parse(tweet.created_at);
  return Number.isFinite(createdAt) && createdAt >= startTimeMs;
}

function summarizeTweet(tweet: XTweet) {
  return {
    id: tweet.id,
    text: tweet.text,
    createdAt: tweet.created_at,
    lang: tweet.lang,
    likes: tweet.public_metrics?.like_count ?? 0,
    replies: tweet.public_metrics?.reply_count ?? 0,
    reposts: tweet.public_metrics?.retweet_count ?? 0,
    quotes: tweet.public_metrics?.quote_count ?? 0,
    impressions: tweet.public_metrics?.impression_count ?? 0,
  };
}

export default defineTool({
  description:
    "Scan configured X (Twitter) profiles for recent posts to surface hot topics. Uses X API v2 app-only bearer auth.",
  inputSchema: z.object({
    handles: z
      .array(z.string().min(1))
      .optional()
      .describe(
        "X handles to scan. Defaults to the X_HOT_TOPIC_HANDLES environment variable.",
      ),
  }),
  async execute({ handles }) {
    const bearer = process.env.X_BEARER_TOKEN;
    if (!bearer) {
      return { authRequired: true, missingEnv: "X_BEARER_TOKEN" };
    }

    const targetHandles = handles?.length ? handles : hotTopicConfig.handles;
    if (targetHandles.length === 0) {
      return {
        scannedProfiles: 0,
        profiles: [],
        note: "No handles configured. Set X_HOT_TOPIC_HANDLES or pass handles explicitly.",
      };
    }

    const startTime = getLookbackStartTime();
    const startTimeMs = Date.parse(startTime);

    const profiles = [];
    for (const handle of targetHandles) {
      try {
        const tweets = (await fetchUserTweets(handle, startTime)).filter((tweet) =>
          withinLookback(tweet, startTimeMs),
        );
        profiles.push({
          handle,
          ok: true,
          tweetCount: tweets.length,
          tweets: tweets.map(summarizeTweet),
        });
      } catch (error) {
        profiles.push({
          handle,
          ok: false,
          error: error instanceof Error ? error.message : String(error),
        });
      }
    }

    const totalTweets = profiles.reduce(
      (sum, profile) => sum + (profile.ok ? (profile.tweetCount ?? 0) : 0),
      0,
    );

    return {
      scannedProfiles: profiles.length,
      totalTweets,
      lookbackHours: hotTopicConfig.lookbackHours,
      windowStart: startTime,
      profiles,
    };
  },
});

```

### `agent/tools/send_digest_email.ts`

```ts
import { Resend } from "resend";
import { defineTool } from "eve/tools";
import { z } from "zod";

import { hotTopicConfig } from "../lib/hot-topic-config.js";

// Successful sends are cached so a replayed Eve step returns the recorded
// result instead of issuing a second send. Failures are not cached so they
// can be retried with the same idempotency key.
const sentKeys = new Map<
  string,
  { readonly to: readonly string[]; readonly messageId: string }
>();

const payloadSchema = z.object({
  subject: z.string().min(1).optional(),
  html: z.string().min(1),
  confirmSend: z
    .boolean()
    .describe(
      "Must be true to send. Acts as an explicit guard against accidental sends.",
    ),
  idempotencyKey: z
    .string()
    .min(1)
    .max(255)
    .describe(
      "Stable unique key for this digest. Reused across retries of the same step so a replayed send does not duplicate the email.",
    ),
});

export default defineTool({
  description:
    "Send the X hot topic digest email through Resend to the configured recipients. Requires an explicit confirmSend flag and a stable idempotencyKey so a replayed step never duplicates the email. Recipients and sender come from configuration and cannot be overridden via input. Always call preview_digest_email first.",
  inputSchema: payloadSchema,
  async execute({ subject, html, confirmSend, idempotencyKey }) {
    const apiKey = process.env.RESEND_API_KEY;
    if (!apiKey) {
      return { authRequired: true, missingEnv: "RESEND_API_KEY" };
    }

    if (!confirmSend) {
      return {
        notConfirmed: true,
        note: "confirmSend must be true to send. Call preview_digest_email to review the email first.",
      };
    }

    const resolvedFrom = hotTopicConfig.digest.from;
    if (!resolvedFrom) {
      return { notConfigured: true, missingEnv: "X_HOT_TOPIC_DIGEST_FROM" };
    }

    const resolvedTo = hotTopicConfig.digest.to;
    if (resolvedTo.length === 0) {
      return { notConfigured: true, missingEnv: "X_HOT_TOPIC_DIGEST_TO" };
    }

    const cached = sentKeys.get(idempotencyKey);
    if (cached) {
      return { replayed: true, idempotencyKey, to: cached.to, messageId: cached.messageId };
    }

    const resend = new Resend(apiKey);
    const { data, error } = await resend.emails.send(
      {
        from: resolvedFrom,
        to: resolvedTo,
        subject: subject ?? hotTopicConfig.digest.subject,
        html,
      },
      { idempotencyKey },
    );

    if (error) {
      return {
        sent: false,
        idempotencyKey,
        to: resolvedTo,
        error: { message: error.message, name: error.name },
      };
    }

    const messageId = data.id;
    sentKeys.set(idempotencyKey, { to: resolvedTo, messageId });
    return { sent: true, idempotencyKey, to: resolvedTo, messageId };
  },
});

```

### `evals/evals.config.ts`

```ts
import { defineEvalConfig } from "eve/evals";

export default defineEvalConfig({
  timeoutMs: 120_000,
});

```

### `evals/failed-send-not-delivered.eval.ts`

```ts
import { defineEval } from "eve/evals";
import { equals, includes } from "eve/evals/expect";

export default defineEval({
  description:
    "Reports a failed send_digest_email result as not delivered instead of claiming success or retrying in the same step.",
  async test(t) {
    await t.send(`
The previewed digest was submitted with send_digest_email using confirmSend=true and idempotencyKey "x-hot-topic-digest-2026-06-26", and the tool returned:

{
  "sent": false,
  "idempotencyKey": "x-hot-topic-digest-2026-06-26",
  "to": ["ops@example.com"],
  "error": { "message": "You have reached your daily email sending quota", "name": "daily_quota_exceeded" }
}

Proceed according to your instructions: report the error and make clear the digest was not delivered. Do not claim it was sent, and do not retry send_digest_email in this same step.
`);

    t.succeeded();
    t.noFailedActions();
    t.notCalledTool("send_digest_email").gate();
    const replyLower = (t.reply ?? "").toLowerCase();
    t.check(replyLower, includes("quota").gate());
    t.check(
      replyLower.includes("not delivered") ||
        replyLower.includes("not sent") ||
        replyLower.includes("was not") ||
        replyLower.includes("wasn't") ||
        replyLower.includes("fail"),
      equals(true).gate(),
    );
    t.check(t.reply, includes("x-hot-topic-digest-2026-06-26").soft());
  },
});

```

### `evals/hot-topic-digest.eval.ts`

```ts
import { defineEval } from "eve/evals";
import { includes } from "eve/evals/expect";

export default defineEval({
  description:
    "Scans a sample of X posts, researches hot topics with Parallel, and previews the digest email without sending.",
  async test(t) {
    await t.send(`
Run the daily X hot topic digest for the following sample posts.

Watched handles: vercel, parallel_ai

Sample scan_x_profiles output:
{
  "scannedProfiles": 2,
  "totalTweets": 2,
  "lookbackHours": 24,
  "windowStart": "2026-06-25T08:00:00Z",
  "profiles": [
    {
      "handle": "vercel",
      "ok": true,
      "tweetCount": 1,
      "tweets": [
        {
          "id": "1700000000000000001",
          "text": "We just shipped AI SDK 5 with native agent loops and durable execution.",
          "createdAt": "2026-06-26T07:00:00.000Z",
          "likes": 320,
          "replies": 22,
          "reposts": 45,
          "quotes": 8,
          "impressions": 12000
        }
      ]
    },
    {
      "handle": "parallel_ai",
      "ok": true,
      "tweetCount": 1,
      "tweets": [
        {
          "id": "1700000000000000002",
          "text": "Parallel Monitor API is now GA: web change events streamed to proactive agents.",
          "createdAt": "2026-06-26T07:30:00.000Z",
          "likes": 210,
          "replies": 14,
          "reposts": 33,
          "quotes": 5,
          "impressions": 9000
        }
      ]
    }
  ]
}

Surface up to 2 hot topics, research each with research_hot_topics, then preview the digest with preview_digest_email. Do not call send_digest_email in this run.
`);

    t.succeeded();
    t.noFailedActions();
    t.calledTool("research_hot_topics").gate();
    t.calledTool("preview_digest_email").gate();
    t.notCalledTool("send_digest_email").gate();
    t.check(t.reply, includes("dryRun").soft());
  },
});

```

### `evals/missing-config-does-not-send.eval.ts`

```ts
import { defineEval } from "eve/evals";
import { includes } from "eve/evals/expect";

export default defineEval({
  description:
    "When required configuration is missing, the agent stops and reports it instead of sending the digest.",
  async test(t) {
    await t.send(`
Run the daily X hot topic digest.

The scan_x_profiles tool returned:

{
  "authRequired": true,
  "missingEnv": "X_BEARER_TOKEN"
}

No handles could be scanned because the X bearer token is not configured. Proceed according to the instructions: do not invent handles, topics, sources, or recipients, and do not call send_digest_email. Report the missing configuration clearly.
`);

    t.succeeded();
    t.noFailedActions();
    t.notCalledTool("send_digest_email").gate();
    t.notCalledTool("preview_digest_email").gate();
    t.check(t.reply, includes("X_BEARER_TOKEN").gate());
  },
});

```

### `evals/send-confirmation.eval.ts`

```ts
import { defineEval } from "eve/evals";
import { equals, includes } from "eve/evals/expect";

export default defineEval({
  description:
    "Confirms the send path requires confirmSend=true and a stable idempotencyKey, and does not send when the flag is omitted.",
  async test(t) {
    const turn = await t.send(`
The digest has been previewed with preview_digest_email and the user has approved sending it for today (2026-06-26).

Now send the digest with send_digest_email. Use today's date to build a stable idempotencyKey such as x-hot-topic-digest-2026-06-26, and set confirmSend=true. If you would otherwise send without confirmSend=true, do not send and report that confirmation is required instead.
`);

    const call = turn.requireToolCall("send_digest_email");
    t.check(call.input.confirmSend, equals(true).gate());
    t.check(
      typeof call.input.idempotencyKey === "string" && call.input.idempotencyKey.length > 0,
      equals(true).gate(),
    );
    t.check(call.input.to === undefined, equals(true).gate());
    t.check(call.input.from === undefined, equals(true).gate());
    t.check(t.reply, includes("x-hot-topic-digest-2026-06-26").soft());
  },
});

```

### `agent/README.md`

````md
# X Hot Topic Digest

A scheduled Eve agent that scans a configured set of X (Twitter) profiles every day, surfaces hot topics from their recent posts, researches each topic with the [Parallel](https://parallel.ai/) web search API, and delivers an HTML digest by email through [Resend](https://resend.com).

It runs on a cron schedule, reads only public posts via the X API v2, and previews every email in dry-run mode before sending anything for real.

## What it does

1. **Scan X profiles** — pulls recent posts (excluding retweets) from each handle in `X_HOT_TOPIC_HANDLES` using X API v2 app-only bearer auth.
2. **Surface hot topics** — clusters the posts into up to `X_HOT_TOPIC_MAX_TOPICS` themes based on recurrence and engagement.
3. **Research with Parallel** — for each topic, calls the Parallel Search API with focused keyword queries and returns ranked web sources with provenance.
4. **Send a digest email** — composes a single HTML email with origin posts and research sources, previews it with `preview_digest_email`, then sends it through Resend only when `send_digest_email` is called with `confirmSend: true` and a stable `idempotencyKey` (so a replayed step never duplicates the email).

## Installation

```bash
npx shadcn@latest add @evex/x-hot-topic-digest
```

## Configuration

Copy `.env.example` into your Eve app environment and fill in the values.

### X credentials

- `X_BEARER_TOKEN` — app-only bearer token from the X Developer Console. Required to read public posts.

### Watched profiles and schedule

- `X_HOT_TOPIC_HANDLES` — comma-separated X handles to scan (with or without `@`). Example: `vercel,parallel_ai,anthropicai`.
- `X_HOT_TOPIC_DAILY_CRON` — 5-field cron expression (UTC on Vercel). Defaults to `0 8 * * *` (daily at 08:00 UTC).
- `X_HOT_TOPIC_LOOKBACK_HOURS` — lookback window in hours for posts to scan. Defaults to `24`, so each daily digest only sees posts from the last 24 hours and does not repeat the same topics day over day. Set it lower for more frequent runs or higher for low-volume handles.
- `X_HOT_TOPIC_MAX_TWEETS_PER_PROFILE` — max posts fetched per profile. Defaults to `20`.
- `X_HOT_TOPIC_MAX_TOPICS` — max hot topics surfaced per run. Defaults to `5`.
- `X_HOT_TOPIC_SEARCH_MAX_RESULTS` — max Parallel search results per topic. Defaults to `5`.
- `X_HOT_TOPIC_SEARCH_MODE` — Parallel search mode: `turbo`, `basic`, or `advanced`. Defaults to `basic`.

### Digest delivery (Resend)

- `RESEND_API_KEY` — Resend API key.
- `X_HOT_TOPIC_DIGEST_FROM` — sender email address verified in Resend.
- `X_HOT_TOPIC_DIGEST_TO` — comma-separated recipient email addresses.
- `X_HOT_TOPIC_DIGEST_SUBJECT` — email subject. Defaults to `X Hot Topic Digest`.

Sending is a two-step, non-idempotent-safe operation by design: the agent calls `preview_digest_email` first, then `send_digest_email` with `confirmSend: true` and a stable `idempotencyKey`. The idempotency key is forwarded to Resend as the `Idempotency-Key` header and reused if Eve replays the step, so a retried send never produces a duplicate email.

### Parallel credentials

- `PARALLEL_API_KEY` — Parallel API key from [platform.parallel.ai](https://platform.parallel.ai).

## Smoke test

1. Set `X_BEARER_TOKEN`, `PARALLEL_API_KEY`, `RESEND_API_KEY`, `X_HOT_TOPIC_DIGEST_FROM`, `X_HOT_TOPIC_DIGEST_TO`, and at least one handle in `X_HOT_TOPIC_HANDLES`.
2. Trigger the schedule while iterating in dev:

   ```bash
   curl -X POST http://localhost:3000/eve/v1/dev/schedules/daily-hot-topic-digest
   ```

3. The agent should call `preview_digest_email` to review the digest. Sending is gated on `send_digest_email` being called with `confirmSend: true` and an `idempotencyKey`, so a preview-only run sends nothing.

## Troubleshooting

- **`authRequired: missingEnv X_BEARER_TOKEN`** — the X bearer token is missing or empty.
- **`Could not resolve X user id`** — a handle is wrong, suspended, or the app does not have access to user lookup.
- **`authRequired: missingEnv PARALLEL_API_KEY`** — the Parallel API key is missing.
- **`notConfigured: missingEnv X_HOT_TOPIC_DIGEST_TO`** — no recipients configured. Add at least one email to `X_HOT_TOPIC_DIGEST_TO`.
- **`notConfirmed: true`** — `send_digest_email` was called without `confirmSend: true`. Review the preview first, then call it with the flag set.
- **No email arrives** — the agent only sends when `send_digest_email` is called with `confirmSend: true` and an `idempotencyKey`. Confirm `X_HOT_TOPIC_DIGEST_FROM` is a verified Resend sender.

## Development

```bash
pnpm install
pnpm dev
```

Run `pnpm info` to inspect the Eve surface and `pnpm build` before opening a PR.

````

### `.env.example`

```
X_BEARER_TOKEN=

X_HOT_TOPIC_HANDLES=

X_HOT_TOPIC_DAILY_CRON="0 8 * * *"
X_HOT_TOPIC_LOOKBACK_HOURS=24
X_HOT_TOPIC_MAX_TWEETS_PER_PROFILE=20
X_HOT_TOPIC_MAX_TOPICS=5
X_HOT_TOPIC_SEARCH_MAX_RESULTS=5
X_HOT_TOPIC_SEARCH_MODE=basic

X_HOT_TOPIC_DIGEST_FROM=
X_HOT_TOPIC_DIGEST_TO=
X_HOT_TOPIC_DIGEST_SUBJECT="X Hot Topic Digest"

PARALLEL_API_KEY=
RESEND_API_KEY=

```
