# X Draft Assistant

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 creates **three draft candidates** for X in [Typefully](https://typefully.com) so a human can review and publish them.

- Install: `npx shadcn@latest add @evex/x-draft-assistant`
- Category: general
- 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, zod@4.3.6
- Web page: https://www.evex.sh/agents/x-draft-assistant
- This document: https://www.evex.sh/agents/x-draft-assistant.md

## Overview

X Draft Assistant is a scheduled eve agent that turns what a watched set of X (Twitter) accounts posted in the last 24 hours into three ready-to-review draft posts in Typefully. Every day it scans the handles listed in X_HOT_TOPIC_HANDLES via the X API v2, clusters their recent posts into hot topics, researches each topic with the Parallel web search API, and writes three distinct draft candidates with cited sources.

You interact with it through a cron schedule rather than chat: the daily-x-drafts schedule fires at 08:00 UTC by default (configurable with X_HOT_TOPIC_DAILY_CRON), and the output lands as unscheduled drafts in your Typefully social set. The agent never publishes, schedules, replies, or likes anything, so a human always makes the final call on what goes live.

The safety model around draft creation is what makes this workable for teams that review before publishing. The agent previews every candidate in dry-run mode first, only creates drafts after an explicit confirmCreate flag, and attaches a per-run idempotency key to each draft so a retried step never produces a duplicate in Typefully. It labels posts with the X made-with-AI disclosure by default.

## How it works

1. On each scheduled run, the scan_x_profiles tool pulls recent posts (excluding retweets) from every handle in X_HOT_TOPIC_HANDLES using X API v2 app-only bearer auth, scoped to the X_HOT_TOPIC_LOOKBACK_HOURS window (default 24 hours) so topics do not repeat day over day.
2. The agent clusters those posts into up to X_HOT_TOPIC_MAX_TOPICS hot topics (default 5), treating recurring themes, launches, debates, or posts with outsized engagement as candidates and merging near-duplicates.
3. For each topic, the research_hot_topics tool queries the Parallel Search API with 2-3 focused keyword queries and returns up to X_HOT_TOPIC_SEARCH_MAX_RESULTS ranked web sources with provenance, in turbo, basic, or advanced mode.
4. Before drafting, the agent loads two skills: typefully-best-practices for X automation compliance and the exactly-once creation model, and social for hook formulas, post templates, and platform limits.
5. It then writes exactly X_HOT_TOPIC_DRAFT_COUNT (default 3) distinct candidates, each a single tweet or a 1-5 post thread within the 280-character limit, citing only post URLs returned by scan_x_profiles, and previews everything with preview_x_draft.
6. Finally, create_x_drafts creates the drafts in Typefully only when called with confirmCreate true and a unique idempotency key per draft; a bundled eval suite verifies the confirmation gate, the no-retry rule on failed creates, and that missing configuration never results in created drafts.

## Use cases

### Daily content pipeline for a startup account

Watch your own company handle plus a few competitors and ecosystem accounts. Each morning three researched draft candidates appear in Typefully, so whoever runs the account starts the day choosing between angles instead of staring at a blank composer.

### Riding launch and announcement waves

Point X_HOT_TOPIC_HANDLES at accounts like vercel or anthropicai. When they ship something, the agent surfaces it as a hot topic, backs it with Parallel web sources, and drafts commentary while the news is still fresh.

### Developer relations topic monitoring

A DevRel team tracks framework maintainers and community voices. The agent condenses the last 24 hours into at most five topics with cited sources, giving the team both draft posts and a quick research digest per run.

### Compliance-safe AI drafting

Because the agent never publishes or schedules drafts, labels them with the X made-with-AI disclosure by default, and deduplicates them with idempotency keys, teams with review requirements can adopt LLM drafting without risking unreviewed or duplicate posts.

## Requirements

- `X_BEARER_TOKEN`: App-only bearer token used to read public posts through the X API v2. Create an app in the X Developer Console and copy its bearer token.
- `X_HOT_TOPIC_HANDLES`: Comma-separated list of X handles to scan, with or without the @ prefix (for example vercel,parallel_ai,anthropicai). The agent stops and reports missing configuration if this is empty.
- `PARALLEL_API_KEY`: API key for the Parallel Search API, used to research each hot topic with ranked web sources. Get one at platform.parallel.ai.
- `TYPEFULLY_API_KEY`: Typefully API key used to create drafts and manage tags. Generate it from the API section of your Typefully settings at typefully.com/?settings=api.
- `TYPEFULLY_SOCIAL_SET_ID`: The Typefully social set (account) the drafts are created under. Find it by listing your social sets via the Typefully API or copying it from the Typefully URL for that account.
- `X_HOT_TOPIC_DAILY_CRON`: Optional 5-field cron expression controlling when the daily run fires, evaluated in UTC on Vercel. Defaults to 0 8 * * * (08:00 UTC daily).
- `X_HOT_TOPIC_DRAFT_TAG`: Optional Typefully tag slug attached to every created draft. If the tag does not exist yet, the agent can list tags and create it on demand. Leave empty to skip tagging.

## FAQ

### How do I install and run it?

Install with npx shadcn@latest add @evex/x-draft-assistant, copy .env.example into your eve app environment, and fill in the X, Parallel, and Typefully credentials plus at least one handle. In dev you can trigger a run manually by POSTing to /eve/v1/dev/schedules/daily-x-drafts.

### Can it publish or schedule posts on X?

No, by design. The agent only creates drafts in Typefully in draft status; it never publishes, schedules, replies, likes, or reposts. A human reviews the three candidates in Typefully and decides what to publish.

### Which model does the agent use?

The agent config sets deepseek/deepseek-v4-flash as the model in agent.ts. Since it is a standard eve agent definition, you can swap in another model supported by your eve deployment by editing that single line.

### How does it avoid creating duplicate drafts?

Creation is a two-step operation: preview_x_draft first, then create_x_drafts with confirmCreate true and a unique idempotency key per draft, derived from the run's lookback window start. Replays within the same Node process return the cached result instead of posting again; replays across a serverless cold start can still re-post, which a durable store would be needed to close.

### What limits and quotas should I know about?

Posts per profile are clamped between 5 and the X API maximum of 100 (default 20), topics per run default to 5, and each post respects the 280-character X limit. If Typefully returns a 429 rate limit, the agent does not retry in the same step; it defers to a later run reusing the same idempotency keys.

## Files installed

- `agent/agent.ts`
- `agent/instructions.md`
- `agent/lib/hot-topic-config.ts`
- `agent/lib/typefully-client.ts`
- `agent/schedules/daily-x-drafts.ts`
- `agent/skills/social/references/platform-limits.md`
- `agent/skills/social/references/post-templates.md`
- `agent/skills/social/SKILL.md`
- `agent/skills/typefully-best-practices/references/exactly-once.md`
- `agent/skills/typefully-best-practices/references/x-automation.md`
- `agent/skills/typefully-best-practices/SKILL.md`
- `agent/tools/create_typefully_tag.ts`
- `agent/tools/create_x_drafts.ts`
- `agent/tools/list_typefully_tags.ts`
- `agent/tools/preview_x_draft.ts`
- `agent/tools/research_hot_topics.ts`
- `agent/tools/scan_x_profiles.ts`
- `evals/create-confirmation.eval.ts`
- `evals/evals.config.ts`
- `evals/failed-create-no-retry.eval.ts`
- `evals/missing-config-does-not-create.eval.ts`
- `evals/x-draft-assistant.eval.ts`
- `agent/README.md`
- `.env.example`

## File contents

### `agent/agent.ts`

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

export default defineAgent({
  model: "deepseek/deepseek-v4-flash",
});

```

### `agent/instructions.md`

```md
# Mission
Produce three X (Twitter) draft candidates every day from hot topics surfaced on a
watched set of profiles, researched with the Parallel web search API, and created
as drafts in Typefully for a human to review and publish.

# Workflow
1. Load the typefully-best-practices skill before drafting or creating any X
   draft. The skill encodes X automation compliance, character limits, and the
   exactly-once draft creation model.
2. Load the social skill before authoring X draft candidates. It provides hook
   formulas, post templates, platform limits, and angle-diversity rules for
   the three-candidate X draft workflow.
3. 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 drafts do 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. Draft exactly `X_HOT_TOPIC_DRAFT_COUNT` (default 3) distinct X post candidates
   from the researched topics. Each candidate is either a single tweet or a short
   thread (1-5 posts). Candidates must differ in angle, tone, or length — not
   just rearranged words — so the user has a real choice. Respect the 280-char X
   limit per post. Cite originating X posts as
   `https://x.com/<handle>/status/<id>` only with handles and ids returned by
   scan_x_profiles. Do not fabricate URLs, post ids, or quotes.
6. Always call preview_x_draft first to review the exact drafts, post lengths,
   target social set, tag, and madeWithAi flag. The social set id, tag, and
   madeWithAi flag come from `TYPEFULLY_SOCIAL_SET_ID`, `X_HOT_TOPIC_DRAFT_TAG`,
   and `X_HOT_TOPIC_DRAFT_MADE_WITH_AI` and cannot be overridden through tool
   input — never try to pass `socialSetId`, `tag`, or `madeWithAi` to the create
   tool. The made-with-AI label defaults to true because these posts are drafted
   by an LLM; only disable it if a human rewrites the posts before publishing.
   If `X_HOT_TOPIC_DRAFT_TAG` names a tag that does not yet exist in the social
   set, call list_typefully_tags first to check whether the tag already exists
   under a different name or slug, then call create_typefully_tag with
   `confirmCreate: true` to create it before creating drafts. Only create a tag
   when it is genuinely missing — reuse an existing tag whenever possible.
7. To create the drafts in Typefully, call create_x_drafts with `confirmCreate:
   true` and a stable, unique `idempotencyKey` per draft. The recommended scheme
   is `x-draft-assistant-<windowStartUtc>-<n>`, where `<windowStartUtc>` is the
   `windowStart` value returned by scan_x_profiles (the RFC3339 UTC start of
   this run's lookback window) and `<n>` is the 1-based index of the draft
   candidate within the run. Using the lookback window start makes the key
   unique per run even when the schedule fires more than once a day, and stable
   across retries of the same run. Reuse the same key if the step is retried so
   a replayed create does not duplicate the draft. Never call create_x_drafts
   without an idempotencyKey per draft, and never reuse the same key across two
   drafts in one call. If create_x_drafts returns a draft with `created: false`
   and an `error`, report the error and do not retry inside the same step.

# Output contract
Return:
- the list of hot topics with origin posts and research sources
- the three X draft candidates (title, posts, scratchpad) as previewed by
  preview_x_draft
- the create result from create_x_drafts when it was called, including each
  draft's idempotencyKey, draftId, and private_url
- any missing configuration that blocked a step

# Guardrails
- Do not publish or schedule drafts in Typefully. The agent only creates drafts.
- Do not disable the X "made with AI" disclosure unless a human rewrites the
  posts before publishing. The posts are drafted by an LLM, so the label is
  required by X's content disclosure policy.
- Do not set a reply target on a draft unless the user explicitly asked for a
  reply to a specific post.
- Do not duplicate text across the three candidates in one run.
- Do not fabricate URLs, excerpts, or post ids. Every citation must come from a
  tool result.
- Do not retry a failed create_x_drafts call inside the same Eve step.
- 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 draft: {
    readonly count: number;
    readonly madeWithAi: boolean;
    readonly tag?: string;
    readonly socialSetId?: 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_DRAFT_COUNT = 3;
const DEFAULT_DRAFT_MADE_WITH_AI = true;

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 parseBoolean = (value: string | undefined, fallback: boolean): boolean => {
  const trimmed = value?.trim().toLowerCase();
  if (trimmed === "true" || trimmed === "1" || trimmed === "yes" || trimmed === "on") {
    return true;
  }
  if (trimmed === "false" || trimmed === "0" || trimmed === "no" || trimmed === "off") {
    return false;
  }
  return fallback;
};

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),
  draft: {
    count: parsePositiveInteger(
      process.env.X_HOT_TOPIC_DRAFT_COUNT,
      DEFAULT_DRAFT_COUNT,
    ),
    madeWithAi: parseBoolean(
      process.env.X_HOT_TOPIC_DRAFT_MADE_WITH_AI,
      DEFAULT_DRAFT_MADE_WITH_AI,
    ),
    tag: optional(process.env.X_HOT_TOPIC_DRAFT_TAG),
    socialSetId: optional(process.env.TYPEFULLY_SOCIAL_SET_ID),
  },
} satisfies HotTopicConfig;

```

### `agent/lib/typefully-client.ts`

```ts
// Typefully Public API v2 client. Minimal surface for creating X drafts.
// Reference: https://typefully.com/docs/api

const TYPEFULLY_API_BASE = "https://api.typefully.com";

export type TypefullyXPost = {
  readonly text: string;
  readonly madeWithAi?: boolean;
};

export type TypefullyCreateDraftInput = {
  readonly socialSetId: string;
  readonly posts: readonly TypefullyXPost[];
  readonly draftTitle?: string;
  readonly scratchpad?: string;
  readonly tags?: readonly string[];
};

export type TypefullyCreateDraftResponse = {
  readonly id: number;
  readonly social_set_id: number;
  readonly status: string;
  readonly preview: string;
  readonly private_url: string;
  readonly share_url?: string | null;
  readonly draft_title?: string | null;
  readonly scheduled_date?: string | null;
  readonly created_at: string;
};

export type TypefullyTagResponse = {
  readonly id: number;
  readonly name: string;
  readonly slug?: string | null;
  readonly social_set_id?: number | null;
};

export type TypefullyCreateTagInput = {
  readonly socialSetId: string;
  readonly name: string;
};

export type TypefullyListTagsResponse = {
  readonly total?: number;
  readonly results?: readonly TypefullyTagResponse[];
  readonly items?: readonly TypefullyTagResponse[];
  readonly data?: readonly TypefullyTagResponse[];
};

export type TypefullyError = {
  readonly message: string;
  readonly status: number;
  readonly body: string;
};

export class TypefullyApiError extends Error {
  readonly status: number;
  readonly body: string;
  constructor(error: TypefullyError) {
    super(error.message);
    this.name = "TypefullyApiError";
    this.status = error.status;
    this.body = error.body;
  }
}

type TypefullyErrorBody = {
  readonly error?: {
    readonly code?: string;
    readonly message?: string;
    readonly details?: readonly {
      readonly message?: string;
      readonly field?: string;
    }[];
  };
};

function summarizeErrorBody(body: string, status: number): string {
  if (!body) {
    return `Typefully API ${status} with no response body.`;
  }
  try {
    const parsed = JSON.parse(body) as TypefullyErrorBody;
    const top = parsed.error?.message;
    if (top) {
      return `Typefully API ${status}: ${top}`;
    }
  } catch {
    // Fall through to the raw slice.
  }
  return `Typefully API ${status}: ${body.slice(0, 500)}`;
}

export async function createTypefullyDraft(
  input: TypefullyCreateDraftInput,
  apiKey: string,
): Promise<TypefullyCreateDraftResponse> {
  const payload = {
    platforms: {
      x: {
        enabled: true,
        posts: input.posts.map((post) => ({
          text: post.text,
          ...(post.madeWithAi ? { made_with_ai: true } : {}),
        })),
        settings: {},
      },
    },
    draft_title: input.draftTitle,
    scratchpad_text: input.scratchpad,
    tags: input.tags,
    share: false,
  };

  const response = await fetch(
    `${TYPEFULLY_API_BASE}/v2/social-sets/${encodeURIComponent(input.socialSetId)}/drafts`,
    {
      method: "POST",
      headers: {
        Authorization: `Bearer ${apiKey}`,
        "Content-Type": "application/json",
      },
      body: JSON.stringify(payload),
    },
  );

  const responseText = await response.text();
  if (!response.ok) {
    throw new TypefullyApiError({
      message: summarizeErrorBody(responseText, response.status),
      status: response.status,
      body: responseText,
    });
  }

  const data = JSON.parse(responseText) as TypefullyCreateDraftResponse;
  return {
    ...data,
    draft_title: data.draft_title ?? input.draftTitle ?? null,
  };
}

export async function createTypefullyTag(
  input: TypefullyCreateTagInput,
  apiKey: string,
): Promise<TypefullyTagResponse> {
  const response = await fetch(
    `${TYPEFULLY_API_BASE}/v2/social-sets/${encodeURIComponent(input.socialSetId)}/tags`,
    {
      method: "POST",
      headers: {
        Authorization: `Bearer ${apiKey}`,
        "Content-Type": "application/json",
      },
      body: JSON.stringify({ name: input.name }),
    },
  );

  const responseText = await response.text();
  if (!response.ok) {
    throw new TypefullyApiError({
      message: summarizeErrorBody(responseText, response.status),
      status: response.status,
      body: responseText,
    });
  }

  return JSON.parse(responseText) as TypefullyTagResponse;
}

export async function listTypefullyTags(
  socialSetId: string,
  apiKey: string,
): Promise<readonly TypefullyTagResponse[]> {
  const response = await fetch(
    `${TYPEFULLY_API_BASE}/v2/social-sets/${encodeURIComponent(socialSetId)}/tags?limit=50`,
    {
      method: "GET",
      headers: {
        Authorization: `Bearer ${apiKey}`,
        "Content-Type": "application/json",
      },
    },
  );

  const responseText = await response.text();
  if (!response.ok) {
    throw new TypefullyApiError({
      message: summarizeErrorBody(responseText, response.status),
      status: response.status,
      body: responseText,
    });
  }

  const parsed = JSON.parse(responseText) as
    | TypefullyListTagsResponse
    | TypefullyTagResponse[];
  if (Array.isArray(parsed)) {
    return parsed;
  }
  return parsed.results ?? parsed.items ?? parsed.data ?? [];
}

```

### `agent/schedules/daily-x-drafts.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 draft assistant.

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. Draft exactly ${hotTopicConfig.draft.count} distinct X post candidates (single tweets or short threads, 280 chars per post, different angles) from the researched topics. Cite originating posts as https://x.com/<handle>/status/<id> only with handles and ids returned by scan_x_profiles.
5. Call preview_x_draft to review the drafts, post lengths, target social set, tag, and madeWithAi flag (defaults to true because the posts are drafted by an LLM).
6. To create the drafts in Typefully, call create_x_drafts with confirmCreate=true and a stable, unique idempotencyKey per draft. The recommended scheme is x-draft-assistant-<windowStartUtc>-<n>, where <windowStartUtc> is the windowStart value returned by scan_x_profiles (RFC3339 UTC, e.g. 2026-06-26T08:00:00Z) and <n> is the 1-based candidate index in this run. Using the lookback window start makes the key unique per run even when the schedule fires more than once a day, and stable across retries of the same run. Reuse the same key if the step is retried so a replayed create does not duplicate the draft.

If any required environment variable is missing (X_BEARER_TOKEN, PARALLEL_API_KEY, TYPEFULLY_API_KEY, TYPEFULLY_SOCIAL_SET_ID), stop and report the missing configuration. Do not invent handles, topics, sources, or draft text. Never call create_x_drafts without confirmCreate=true and a unique idempotencyKey per draft. Do not publish or schedule the drafts; the agent only creates them. Do not disable the X "made with AI" disclosure (X_HOT_TOPIC_DRAFT_MADE_WITH_AI) unless a human rewrites the posts before publishing.`,
});

```

### `agent/skills/social/references/platform-limits.md`

```md
# X (Twitter) limits

| Element | Limit |
|---------|-------|
| Max post chars | 280 |
| Thread length | 1–5 posts for this agent's draft candidates |
| Visible before "more" | ~280 (single post) |
| Link handling | URLs count toward character limit |

For hook formulas and post templates, see [post-templates](./post-templates.md).

```

### `agent/skills/social/references/post-templates.md`

````md
# Post Format Templates

Ready-to-use templates for different platforms and content types.

## Contents
- LinkedIn Post Templates (The Story Post, The Contrarian Take, The List Post, The How-To)
- Twitter/X Thread Templates (The Tutorial Thread, The Story Thread, The Breakdown Thread)
- Instagram Templates (The Carousel Hook, The Reel Script)
- Hook Formulas (Curiosity Hooks, Story Hooks, Value Hooks, Contrarian Hooks, Social Proof Hooks)

## LinkedIn Post Templates

### The Story Post
```
[Hook: Unexpected outcome or lesson]

[Set the scene: When/where this happened]

[The challenge you faced]

[What you tried / what happened]

[The turning point]

[The result]

[The lesson for readers]

[Question to prompt engagement]
```

### The Contrarian Take
```
[Unpopular opinion stated boldly]

Here's why:

[Reason 1]
[Reason 2]
[Reason 3]

[What you recommend instead]

[Invite discussion: "Am I wrong?"]
```

### The List Post
```
[X things I learned about [topic] after [credibility builder]:

1. [Point] — [Brief explanation]

2. [Point] — [Brief explanation]

3. [Point] — [Brief explanation]

[Wrap-up insight]

Which resonates most with you?
```

### The How-To
```
How to [achieve outcome] in [timeframe]:

Step 1: [Action]
↳ [Why this matters]

Step 2: [Action]
↳ [Key detail]

Step 3: [Action]
↳ [Common mistake to avoid]

[Result you can expect]

[CTA or question]
```

---

## Twitter/X Thread Templates

### The Tutorial Thread
```
Tweet 1: [Hook + promise of value]

"Here's exactly how to [outcome] (step-by-step):"

Tweet 2-7: [One step per tweet with details]

Final tweet: [Summary + CTA]

"If this was helpful, follow me for more on [topic]"
```

### The Story Thread
```
Tweet 1: [Intriguing hook]

"[Time] ago, [unexpected thing happened]. Here's the full story:"

Tweet 2-6: [Story beats, building tension]

Tweet 7: [Resolution and lesson]

Final tweet: [Takeaway + engagement ask]
```

### The Breakdown Thread
```
Tweet 1: [Company/person] just [did thing].

Here's why it's genius (and what you can learn):

Tweet 2-6: [Analysis points]

Tweet 7: [Your key takeaway]

"[Related insight + follow CTA]"
```

---

## Instagram Templates

### The Carousel Hook
```
[Slide 1: Bold statement or question]
[Slides 2-9: One point per slide, visual + text]
[Slide 10: Summary + CTA]

Caption: [Expand on the topic, add context, include CTA]
```

### The Reel Script
```
Hook (0-2 sec): [Pattern interrupt or bold claim]
Setup (2-5 sec): [Context for the tip]
Value (5-25 sec): [The actual advice/content]
CTA (25-30 sec): [Follow, comment, share, link]
```

---

## Hook Formulas

The first line determines whether anyone reads the rest.

### Curiosity Hooks
- "I was wrong about [common belief]."
- "The real reason [outcome] happens isn't what you think."
- "[Impressive result] — and it only took [surprisingly short time]."
- "Nobody talks about [insider knowledge]."

### Story Hooks
- "Last week, [unexpected thing] happened."
- "I almost [big mistake/failure]."
- "3 years ago, I [past state]. Today, [current state]."
- "[Person] told me something I'll never forget."

### Value Hooks
- "How to [desirable outcome] (without [common pain]):"
- "[Number] [things] that [outcome]:"
- "The simplest way to [outcome]:"
- "Stop [common mistake]. Do this instead:"

### Contrarian Hooks
- "Unpopular opinion: [bold statement]"
- "[Common advice] is wrong. Here's why:"
- "I stopped [common practice] and [positive result]."
- "Everyone says [X]. The truth is [Y]."

### Social Proof Hooks
- "We [achieved result] in [timeframe]. Here's the full story:"
- "[Number] people asked me about [topic]. Here's my answer:"
- "[Authority figure] taught me [lesson]."

````

### `agent/skills/social/SKILL.md`

```md
---
name: social
description: Draft X posts with strong hooks, distinct angles, and scroll-stopping structure for the three-candidate workflow.
---

# X drafting

Use when authoring X draft candidates. This agent produces three distinct posts or
short threads per run — not multi-platform social strategy.

## Hooks

The first line determines whether anyone reads the rest.

### Curiosity

- "I was wrong about [common belief]."
- "The real reason [outcome] happens isn't what you think."
- "[Impressive result] — and it only took [surprisingly short time]."

### Story

- "Last week, [unexpected thing] happened."
- "I almost [big mistake/failure]."
- "3 years ago, I [past state]. Today, [current state]."

### Value

- "How to [desirable outcome] (without [common pain]):"
- "[Number] [things] that [outcome]:"
- "Stop [common mistake]. Do this instead:"

### Contrarian

- "Unpopular opinion: [bold statement]"
- "[Common advice] is wrong. Here's why:"
- "I stopped [common practice] and [positive result]."

For more hook and post templates, see [post-templates](./references/post-templates.md).

## Draft rules

- Each candidate must differ in **angle, tone, or length** — not rearranged words.
- Lead with the takeaway. Threads read top-to-bottom; later posts add evidence or
  nuance.
- One idea per post. Stay within the 280-character limit per post.
- Cite originating posts only with handles and ids from `scan_x_profiles`.
- Do not fabricate URLs, quotes, or engagement claims.

For character limits, see [platform-limits](./references/platform-limits.md).

## Angle diversity

When drafting three candidates from one hot topic, vary at least two of:

- **Stance** — support, challenge, or add nuance to the signal
- **Format** — single tweet vs short thread
- **Framing** — practitioner takeaway, contrarian read, or "what this means next"

**Done when** each candidate would make sense as the only draft in the run.

```

### `agent/skills/typefully-best-practices/references/exactly-once.md`

````md
# Exactly Once

Ensuring a Typefully draft is created exactly once across Eve step replays.

## The problem

Eve replays completed steps from their recorded result, but a step interrupted
mid-execution re-runs. If a `create_x_drafts` call is interrupted after the
Typefully POST succeeds but before the result is recorded, a replay issues a
second POST and creates a duplicate draft.

The Typefully v2 API does not accept a server-side idempotency key, so the
defense is in-process: a per-draft `idempotencyKey` plus a cache of successful
creates keyed by that key.

### Scope of the in-process cache

The cache lives in the Node process that ran the create. It protects against
Eve step replays inside that process (the common case: a step interrupted
mid-execution re-runs in the same session). It does **not** protect against
replays that cross a process boundary — a serverless cold start, a redeploy,
or a process restart will see an empty cache and issue a second POST if Eve
replays the step there. A durable store (Redis, Postgres, or another
shared-state backend) would be needed to close that gap, and is out of scope
for this agent. The mitigations in place are:

- The recommended `idempotencyKey` is derived from the run's lookback window
  start, so a re-triggered run with the same window reuses the same key — but
  only the in-process cache can short-circuit it.
- `confirmCreate` must be `true` before any POST goes out, so accidental
  creates are gated.
- The agent never publishes or schedules drafts, so a duplicate draft is
  reviewable noise in Typefully, not a public double-post.

## Solution: per-draft idempotency keys

Each draft in a `create_x_drafts` call carries a stable `idempotencyKey`. Before
issuing a POST, the tool checks the cache:

- A hit returns the recorded response with `replayed: true` and never issues a
  second POST.
- A miss issues the POST, then stores the response on success. Failures are not
  cached, so the same key can be retried on a later run.

### Key generation strategies

| Strategy | Example | Use when |
|----------|---------|----------|
| Lookback window start (recommended) | `x-draft-assistant-2026-06-26T08:00:00Z-1` | One draft per candidate per run; unique per run even sub-daily |
| Run + topic slug | `x-draft-assistant-2026-06-26T08:00:00Z-ai-sdk-5` | Stable across topic reordering within a run |
| UUID | `crypto.randomUUID()` | No natural key (generate once, reuse on retry) |

**Best practice:** use deterministic keys based on the run's lookback window
start (returned by `scan_x_profiles` as `windowStart`) and the candidate index.
The window start is unique per run even when the schedule fires more than once
a day, and is stable across retries of the same run. If the same logical create
is retried, the same key must be regenerated. Avoid `Date.now()` or random
values generated fresh on each attempt — a fresh value per attempt breaks
exactly-once.

### Duplicate keys inside one call

Each draft in a single `create_x_drafts` call must have a unique
`idempotencyKey`. The tool rejects a call with duplicate keys before any POST is
issued, so a misconfigured run cannot create one draft and silently drop another.

## Result shape: distinguish `created`, `replayed`, and failures

The tool returns one entry per draft, tagged so the caller can tell them apart:

```typescript
type DraftResult =
  | { created: true; draftId; privateUrl; ... }
  | { replayed: true; draftId; privateUrl; ... }
  | { created: false; error: { message; status? } };
```

A `replayed` entry is a success — the draft already exists and the replay did not
duplicate it. A `created: false` entry is a failure that can be retried with the
same key on a later run.

## Retry logic

A failed create should not be retried inside the same Eve step. The Typefully
per-social-set rate limit on `drafts.create` is small, and a tight retry loop
will burn through it. Surface the failure in the output and let the user retry on
a later run, reusing the same `idempotencyKey` so a successful retry does not
duplicate the draft.

| Error type | Retry? | Notes |
|------------|--------|-------|
| 429 (rate limit) | No, defer to a later run | Wait for the rate limit window |
| 5xx (server error) | Yes, on a later run | Transient, likely to resolve |
| 4xx (client error) | No | Fix the request first |
| Network timeout | Yes, on a later run | Transient |

## The `confirmCreate` guard

`create_x_drafts` requires `confirmCreate: true` before it issues any POST. This
is a separate guard from the idempotency key: the key makes replays safe, the
flag makes accidental creates impossible. Always call `preview_x_draft` first,
review the candidates, then call `create_x_drafts` with the flag set.

## Related

- [X Automation](./x-automation.md) — content and engagement rules for X drafts

````

### `agent/skills/typefully-best-practices/references/x-automation.md`

```md
# X Automation Compliance

X's automation rules govern anything posted through the Typefully API on behalf of
an account. The agent only creates drafts — it never publishes or schedules — but
the same rules govern the content that lands in the queue.

## Rules

### No duplicate content across drafts in the same run

Each of the three draft candidates must take a distinct angle on the same hot
topic. Reusing the same text across drafts risks an X duplicate-content flag and
reduces the value of offering the user three options.

### No unsolicited automated replies

Never set a reply target on a draft unless the user explicitly asked for a reply
to a specific post. The agent creates top-level posts only. Replying to
unrelated accounts is one of the fastest ways to get an account flagged.

### No trending manipulation

Do not stuff hashtags or pile onto a trending topic to game visibility. The
drafts react to a real signal from watched profiles, not to the trending tab. If
a topic is genuinely trending, write about it for its substance, not for the
trend.

### No fake engagement

The agent does not like, repost, follow, or reply. It only creates drafts. Do
not add engagement-style framing ("boost this", "retweet if you agree") to draft
text either.

### Label AI-drafted posts

X's content disclosure policy requires a "made with AI" label on posts generated
by an LLM. The agent drafts posts with a model, so every X post is created with
`made_with_ai: true` by default. `X_HOT_TOPIC_DRAFT_MADE_WITH_AI` controls the
flag and defaults to `true`.

Only disable the label (`X_HOT_TOPIC_DRAFT_MADE_WITH_AI=false`) if a human
rewrites the posts before publishing. Disabling it for AI-drafted content
violates X's content disclosure policy and risks account enforcement.

### Respect rate limits

The Typefully API rate-limits draft creation per user and per social set. One
run produces at most three drafts; do not loop create calls to retry a failed
draft in the same step. If a draft fails, surface the error in the output and let
the user retry on a later run.

## Priority order

When you cannot satisfy every rule, fix in this order:

1. Missing "made with AI" label on AI-drafted posts (policy violation, account
   enforcement risk).
2. Duplicate content across the three drafts in the same run.
3. Unsolicited reply target on a draft.
4. Hashtag stuffing or trending manipulation.
5. Engagement-bait framing in the post text.
6. Retrying a failed create in the same step.

## Authoring checklist

- [ ] `X_HOT_TOPIC_DRAFT_MADE_WITH_AI` is `true` (default) unless a human rewrites the posts before publishing
- [ ] Each of the three draft candidates has distinct text and a distinct angle
- [ ] No draft sets a reply target unless the user explicitly asked for a reply
- [ ] No hashtag stuffing, no engagement bait, no trending manipulation
- [ ] No retry loop on a failed `create_x_drafts` call inside one step

## Related

- [Exactly Once](./exactly-once.md) — idempotent draft creation and replay safety

```

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

```md
---
name: typefully-best-practices
description: Draft X posts through Typefully with automation compliance, character limits, and exactly-once draft creation.
---

Guidance for drafting X posts and creating Typefully drafts without violating X's
automation rules or producing duplicate drafts. Apply these rules whenever an X
draft is being authored or created through Typefully.

## X automation compliance

X's automation rules apply to anything posted through the Typefully API on behalf
of an account. The agent only creates drafts; it never publishes or schedules
them, but the same rules govern the content that lands in the queue.

- **No duplicate content across drafts in the same run.** Each of the three draft
  candidates must take a distinct angle on the same hot topic. Reusing the same
  text across drafts risks an X duplicate-content flag.
- **No unsolicited automated replies.** Never set a reply target on a draft
  unless the user explicitly asked for a reply to a specific post. The agent
  creates top-level posts only.
- **No trending manipulation.** Do not stuff hashtags or pile onto a trending
  topic to game visibility. The drafts react to a real signal from watched
  profiles, not to the trending tab.
- **No fake engagement.** The agent does not like, repost, follow, or reply. It
  only creates drafts.
- **Label AI-drafted posts.** X requires a "made with AI" label on LLM-generated
  posts. `X_HOT_TOPIC_DRAFT_MADE_WITH_AI` defaults to `true` and
  `create_x_drafts` sets `made_with_ai: true` on every X post. Only disable the
  label if a human rewrites the posts before publishing.
- **Respect rate limits.** The Typefully API rate-limits draft creation per user
  and per social set. One run produces at most three drafts; do not loop create
  calls to retry a failed draft in the same step.

See [x-automation](./references/x-automation.md) for the full compliance model.

## Exactly-once draft creation

The Typefully v2 API does not accept a server-side idempotency key, so a replayed
Eve step would normally create a second draft. The agent defends against that
with a per-draft `idempotencyKey` plus an in-process cache of successful
creates:

- Derive each key from the run, not from `Date.now()` or a fresh random value.
  A stable scheme is `x-draft-assistant-<windowStartUtc>-<n>`, where
  `<windowStartUtc>` is the `windowStart` value returned by `scan_x_profiles`
  (RFC3339 UTC start of this run's lookback window) and `<n>` is the 1-based index
  of the draft candidate within the run. Anchoring to the lookback window start
  makes the key unique per run even when the schedule fires more than once a
  day, and stable across retries of the same run.
- Each draft in a single `create_x_drafts` call must have a unique key. Duplicate
  keys inside one call are rejected before any POST is issued.
- A replayed step with the same key returns the recorded response instead of
  issuing a second POST. Failures are not cached, so the same key can be retried.
- `confirmCreate` must be `true` before any draft is created. Treat it as a
  guardrail: always call `preview_x_draft` first, then create with the flag set.

See [exactly-once](./references/exactly-once.md) for the idempotency and retry
model in detail.

## Drafting for X

X posts are short, single-purpose, and easy to read in a fast scroll.

- Each post is at most 280 characters. `preview_x_draft` validates this; longer
  posts are rejected before any network call.
- A single-post draft is a tweet. A multi-post draft is a thread: order posts so
  the thread reads top-to-bottom, lead with the takeaway, and let later posts add
  evidence or nuance.
- Keep drafts distinct: the three candidates should differ in angle, length, or
  tone — not just rearranged words.
- Cite the originating X post when its content anchors the draft. Link as
  `https://x.com/<handle>/status/<id>` and only use handles and ids returned by
  `scan_x_profiles`.
- Do not fabricate URLs, post ids, or quotes. Every citation must come from a
  tool result.

## Output discipline

The agent creates drafts only. It never schedules, publishes, or shares them.
Leave the drafts in `draft` status for a human to review in Typefully.

```

### `agent/tools/create_typefully_tag.ts`

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

import { hotTopicConfig } from "../lib/hot-topic-config.js";
import {
  createTypefullyTag,
  TypefullyApiError,
  type TypefullyTagResponse,
} from "../lib/typefully-client.js";

const TAG_NAME_MAX_CHARS = 60;

const tagNameSchema = z
  .string()
  .min(1)
  .max(TAG_NAME_MAX_CHARS, `Typefully tag names must be at most ${TAG_NAME_MAX_CHARS} characters.`);

const inputSchema = z.object({
  name: tagNameSchema.describe(
    "The Typefully tag name to create. Tags are scoped to the configured social set.",
  ),
  confirmCreate: z
    .boolean()
    .describe(
      "Must be true to create the tag in Typefully. Acts as an explicit guard against accidental creates.",
    ),
});

type CreatedTag = {
  readonly created: true;
  readonly tagId: number;
  readonly socialSetId: string;
  readonly name: string;
  readonly slug?: string | null;
};

type FailedTag = {
  readonly created: false;
  readonly name: string;
  readonly error: { readonly message: string; readonly status?: number };
};

type CreateTypefullyTagOutput = CreatedTag | FailedTag;

export default defineTool({
  description:
    "Create a Typefully tag in the configured social set. The target social set comes from TYPEFULLY_SOCIAL_SET_ID and cannot be overridden via input. Use this when X_HOT_TOPIC_DRAFT_TAG references a tag that does not yet exist in the social set; otherwise prefer to reuse an existing tag. Tags are scoped per social set. The agent only creates the tag — it never attaches it to a draft (create_x_drafts uses X_HOT_TOPIC_DRAFT_TAG for that).",
  inputSchema,
  async execute({ name, confirmCreate }): Promise<CreateTypefullyTagOutput> {
    const apiKey = process.env.TYPEFULLY_API_KEY;
    const socialSetId = hotTopicConfig.draft.socialSetId;

    if (!apiKey) {
      return {
        name,
        created: false,
        error: { message: "Missing TYPEFULLY_API_KEY environment variable." },
      };
    }

    if (!socialSetId) {
      return {
        name,
        created: false,
        error: { message: "Missing TYPEFULLY_SOCIAL_SET_ID environment variable." },
      };
    }

    if (!confirmCreate) {
      return {
        name,
        created: false,
        error: {
          message: "confirmCreate must be true to create a tag.",
        },
      };
    }

    try {
      const response: TypefullyTagResponse = await createTypefullyTag(
        { socialSetId, name },
        apiKey,
      );
      return {
        created: true,
        tagId: response.id,
        socialSetId,
        name: response.name,
        slug: response.slug ?? null,
      };
    } catch (error) {
      const message =
        error instanceof TypefullyApiError
          ? error.message
          : error instanceof Error
            ? error.message
            : String(error);
      return error instanceof TypefullyApiError
        ? { name, created: false, error: { message, status: error.status } }
        : { name, created: false, error: { message } };
    }
  },
});

```

### `agent/tools/create_x_drafts.ts`

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

import { hotTopicConfig } from "../lib/hot-topic-config.js";
import {
  createTypefullyDraft,
  TypefullyApiError,
  type TypefullyCreateDraftResponse,
} from "../lib/typefully-client.js";

const X_POST_MAX_CHARS = 280;

const postSchema = z
  .string()
  .min(1)
  .max(X_POST_MAX_CHARS, `X posts must be at most ${X_POST_MAX_CHARS} characters.`);

const draftSchema = z.object({
  idempotencyKey: z
    .string()
    .min(1)
    .max(255)
    .describe(
      "Stable unique key for this draft, scoped to this run. Reused across retries of the same step so a replayed create does not duplicate the draft. Must be unique per draft, not per run.",
    ),
  title: z
    .string()
    .min(1)
    .max(120)
    .describe("Internal Typefully draft title. Not posted to social media."),
  posts: z
    .array(postSchema)
    .min(1)
    .max(25)
    .describe(
      "Ordered X posts that make up the draft. A single post is a tweet; multiple posts are a thread.",
    ),
  scratchpad: z
    .string()
    .max(2_000)
    .optional()
    .describe(
      "Optional private notes attached to the draft in Typefully. Not posted to social media.",
    ),
});

const draftsSchema = z
  .array(draftSchema)
  .min(1)
  .max(5)
  .describe("Up to 5 X draft candidates to create in Typefully.");

const payloadSchema = z.object({
  drafts: draftsSchema,
  confirmCreate: z
    .boolean()
    .describe(
      "Must be true to create drafts in Typefully. Acts as an explicit guard against accidental creates.",
    ),
});

type CreatedDraft = {
  readonly idempotencyKey: string;
  readonly title: string;
  readonly created: true;
  readonly draftId: number;
  readonly socialSetId: string;
  readonly privateUrl: string;
  readonly preview: string;
  readonly status: string;
};

type ReplayedDraft = {
  readonly idempotencyKey: string;
  readonly title: string;
  readonly replayed: true;
  readonly draftId: number;
  readonly socialSetId: string;
  readonly privateUrl: string;
};

type FailedDraft = {
  readonly idempotencyKey: string;
  readonly title: string;
  readonly created: false;
  readonly error: { readonly message: string; readonly status?: number };
};

type CreateXDraftsOutput = {
  readonly socialSetId: string;
  readonly tag?: string;
  readonly madeWithAi: boolean;
  readonly createdCount: number;
  readonly replayedCount: number;
  readonly failedCount: number;
  readonly drafts: readonly (CreatedDraft | ReplayedDraft | FailedDraft)[];
};

// Successful creates are cached so a replayed Eve step returns the recorded
// result instead of issuing a second POST, as long as the replay happens in
// the same Node process. The Typefully v2 API does not accept a server-side
// idempotency key, so the cache is in-process and keyed by the caller-provided
// idempotencyKey. A replay that crosses a process boundary (serverless cold
// start, redeploy, restart) sees an empty cache and will POST again — a
// durable store would be needed to close that gap. Failures are not cached so
// they can be retried with the same key.
const createdCache = new Map<
  string,
  { readonly title: string; readonly socialSetId: string; readonly response: TypefullyCreateDraftResponse }
>();

function duplicateIdempotencyKeys(drafts: readonly { idempotencyKey: string }[]): string[] {
  const seen = new Set<string>();
  const duplicates = new Set<string>();
  for (const draft of drafts) {
    if (seen.has(draft.idempotencyKey)) {
      duplicates.add(draft.idempotencyKey);
    } else {
      seen.add(draft.idempotencyKey);
    }
  }
  return [...duplicates];
}

export default defineTool({
  description:
    "Create one or more X draft candidates in Typefully. Each draft requires a stable idempotencyKey so a replayed step does not duplicate the draft. The target social set, tag, and madeWithAi disclosure come from configuration and cannot be overridden via input. Always call preview_x_draft first. Drafts are saved (not scheduled and not published). When madeWithAi is enabled (default), every X post is labeled as made with AI per X's content disclosure policy.",
  inputSchema: payloadSchema,
  async execute({ drafts, confirmCreate }): Promise<CreateXDraftsOutput> {
    const apiKey = process.env.TYPEFULLY_API_KEY;
    const madeWithAi = hotTopicConfig.draft.madeWithAi;
    if (!apiKey) {
      return {
        socialSetId: hotTopicConfig.draft.socialSetId ?? "",
        madeWithAi,
        createdCount: 0,
        replayedCount: 0,
        failedCount: drafts.length,
        drafts: drafts.map((draft) => ({
          idempotencyKey: draft.idempotencyKey,
          title: draft.title,
          created: false,
          error: { message: "Missing TYPEFULLY_API_KEY environment variable." },
        })),
      };
    }

    if (!confirmCreate) {
      return {
        socialSetId: hotTopicConfig.draft.socialSetId ?? "",
        madeWithAi,
        createdCount: 0,
        replayedCount: 0,
        failedCount: drafts.length,
        drafts: drafts.map((draft) => ({
          idempotencyKey: draft.idempotencyKey,
          title: draft.title,
          created: false,
          error: {
            message:
              "confirmCreate must be true to create drafts. Call preview_x_draft to review them first.",
          },
        })),
      };
    }

    const socialSetId = hotTopicConfig.draft.socialSetId;
    if (!socialSetId) {
      return {
        socialSetId: "",
        madeWithAi,
        createdCount: 0,
        replayedCount: 0,
        failedCount: drafts.length,
        drafts: drafts.map((draft) => ({
          idempotencyKey: draft.idempotencyKey,
          title: draft.title,
          created: false,
          error: { message: "Missing TYPEFULLY_SOCIAL_SET_ID environment variable." },
        })),
      };
    }

    const duplicates = duplicateIdempotencyKeys(drafts);
    if (duplicates.length > 0) {
      return {
        socialSetId,
        madeWithAi,
        createdCount: 0,
        replayedCount: 0,
        failedCount: drafts.length,
        drafts: drafts.map((draft) => ({
          idempotencyKey: draft.idempotencyKey,
          title: draft.title,
          created: false,
          error: {
            message: `Duplicate idempotencyKey "${draft.idempotencyKey}". Each draft needs a unique key.`,
          },
        })),
      };
    }

    const tag = hotTopicConfig.draft.tag;
    const tags = tag ? [tag] : undefined;
    const results: (CreatedDraft | ReplayedDraft | FailedDraft)[] = [];

    for (const draft of drafts) {
      const cached = createdCache.get(draft.idempotencyKey);
      if (cached) {
        results.push({
          idempotencyKey: draft.idempotencyKey,
          title: draft.title,
          replayed: true,
          draftId: cached.response.id,
          socialSetId: cached.socialSetId,
          privateUrl: cached.response.private_url,
        });
        continue;
      }

      try {
        const response = await createTypefullyDraft(
          {
            socialSetId,
            posts: draft.posts.map((post) => ({ text: post, madeWithAi })),
            draftTitle: draft.title,
            scratchpad: draft.scratchpad,
            tags,
          },
          apiKey,
        );
        createdCache.set(draft.idempotencyKey, {
          title: draft.title,
          socialSetId,
          response,
        });
        results.push({
          idempotencyKey: draft.idempotencyKey,
          title: draft.title,
          created: true,
          draftId: response.id,
          socialSetId,
          privateUrl: response.private_url,
          preview: response.preview,
          status: response.status,
        });
      } catch (error) {
        const message =
          error instanceof TypefullyApiError
            ? error.message
            : error instanceof Error
              ? error.message
              : String(error);
        const failedDraft: FailedDraft =
          error instanceof TypefullyApiError
            ? {
                idempotencyKey: draft.idempotencyKey,
                title: draft.title,
                created: false,
                error: { message, status: error.status },
              }
            : {
                idempotencyKey: draft.idempotencyKey,
                title: draft.title,
                created: false,
                error: { message },
              };
        results.push(failedDraft);
      }
    }

    const createdCount = results.filter(isCreatedDraft).length;
    const replayedCount = results.filter(isReplayedDraft).length;
    const failedCount = results.filter(isFailedDraft).length;

    return {
      socialSetId,
      madeWithAi,
      ...(tag ? { tag } : {}),
      createdCount,
      replayedCount,
      failedCount,
      drafts: results,
    };
  },
});

function isCreatedDraft(draft: CreatedDraft | ReplayedDraft | FailedDraft): draft is CreatedDraft {
  return "created" in draft && draft.created === true;
}

function isReplayedDraft(
  draft: CreatedDraft | ReplayedDraft | FailedDraft,
): draft is ReplayedDraft {
  return "replayed" in draft;
}

function isFailedDraft(draft: CreatedDraft | ReplayedDraft | FailedDraft): draft is FailedDraft {
  return "created" in draft && draft.created === false;
}

```

### `agent/tools/list_typefully_tags.ts`

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

import { hotTopicConfig } from "../lib/hot-topic-config.js";
import {
  listTypefullyTags,
  TypefullyApiError,
  type TypefullyTagResponse,
} from "../lib/typefully-client.js";

export default defineTool({
  description:
    "List the existing Typefully tags in the configured social set. Use this before create_typefully_tag to avoid creating a duplicate tag, and to resolve a configured X_HOT_TOPIC_DRAFT_TAG into its existing tag. The target social set comes from TYPEFULLY_SOCIAL_SET_ID and cannot be overridden via input. Tags are scoped per social set.",
  inputSchema: z.object({}),
  async execute(): Promise<
    | {
        readonly socialSetId: string;
        readonly tags: readonly {
          readonly id: number;
          readonly name: string;
          readonly slug?: string | null;
        }[];
      }
    | { readonly authRequired: true; readonly missingEnv: string }
    | { readonly notConfigured: true; readonly missingEnv: string }
    | { readonly failed: true; readonly error: { readonly message: string; readonly status?: number } }
  > {
    const apiKey = process.env.TYPEFULLY_API_KEY;
    if (!apiKey) {
      return { authRequired: true, missingEnv: "TYPEFULLY_API_KEY" };
    }

    const socialSetId = hotTopicConfig.draft.socialSetId;
    if (!socialSetId) {
      return { notConfigured: true, missingEnv: "TYPEFULLY_SOCIAL_SET_ID" };
    }

    try {
      const tags: readonly TypefullyTagResponse[] = await listTypefullyTags(socialSetId, apiKey);
      return {
        socialSetId,
        tags: tags.map((tag) => ({
          id: tag.id,
          name: tag.name,
          slug: tag.slug ?? null,
        })),
      };
    } catch (error) {
      const message =
        error instanceof TypefullyApiError
          ? error.message
          : error instanceof Error
            ? error.message
            : String(error);
      return error instanceof TypefullyApiError
        ? { failed: true, error: { message, status: error.status } }
        : { failed: true, error: { message } };
    }
  },
});

```

### `agent/tools/preview_x_draft.ts`

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

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

const X_POST_MAX_CHARS = 280;

const postSchema = z
  .string()
  .min(1)
  .max(X_POST_MAX_CHARS, `X posts must be at most ${X_POST_MAX_CHARS} characters.`);

const draftSchema = z.object({
  title: z
    .string()
    .min(1)
    .max(120)
    .describe("Internal Typefully draft title. Not posted to social media."),
  posts: z
    .array(postSchema)
    .min(1)
    .max(25)
    .describe(
      "Ordered X posts that make up the draft. A single post is a tweet; multiple posts are a thread.",
    ),
  scratchpad: z
    .string()
    .max(2_000)
    .optional()
    .describe(
      "Optional private notes attached to the draft in Typefully. Not posted to social media.",
    ),
});

const draftsSchema = z
  .array(draftSchema)
  .min(1)
  .max(5)
  .describe("Up to 5 X draft candidates to preview before creating them in Typefully.");

export default defineTool({
  description:
    "Preview one or more X draft candidates without creating them in Typefully. Validates each post against the 280-character X limit, the post count per draft, and resolves the target social set from configuration. Returns the exact payload that create_x_drafts would send, including the madeWithAi flag from configuration. The target social set and tag come from configuration and cannot be overridden via input. Always call preview_x_draft before create_x_drafts.",
  inputSchema: z.object({
    drafts: draftsSchema,
  }),
  async execute({ drafts }) {
    const apiKey = process.env.TYPEFULLY_API_KEY;
    if (!apiKey) {
      return { authRequired: true, missingEnv: "TYPEFULLY_API_KEY" };
    }

    const socialSetId = hotTopicConfig.draft.socialSetId;
    if (!socialSetId) {
      return { notConfigured: true, missingEnv: "TYPEFULLY_SOCIAL_SET_ID" };
    }

    return {
      dryRun: true,
      socialSetId,
      tag: hotTopicConfig.draft.tag ?? null,
      madeWithAi: hotTopicConfig.draft.madeWithAi,
      draftCount: drafts.length,
      drafts: drafts.map((draft) => ({
        title: draft.title,
        postCount: draft.posts.length,
        posts: draft.posts,
        postChars: draft.posts.map((post) => post.length),
        maxChars: X_POST_MAX_CHARS,
        madeWithAi: hotTopicConfig.draft.madeWithAi,
        scratchpad: draft.scratchpad ?? null,
      })),
    };
  },
});

```

### `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;
const MAX_MAX_RESULTS = 100;

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.min(
    Math.max(hotTopicConfig.maxTweetsPerProfile, MIN_MAX_RESULTS),
    MAX_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,
    };
  },
});

```

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

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

export default defineEval({
  description:
    "Confirms the create path requires confirmCreate=true and a stable, unique idempotencyKey per draft, that madeWithAi/socialSetId/tag are never passed as tool input (they come from config), and that the reply mentions the X made-with-AI disclosure.",
  async test(t) {
    const turn = await t.send(`
The three X draft candidates have been previewed with preview_x_draft and the user has approved creating them in Typefully. The scan_x_profiles run for this batch reported windowStart=2026-06-26T08:00:00Z.

Now create the drafts with create_x_drafts. Use the lookback window start and the candidate index to build a stable, unique idempotencyKey per draft such as x-draft-assistant-2026-06-26T08:00:00Z-1, x-draft-assistant-2026-06-26T08:00:00Z-2, and x-draft-assistant-2026-06-26T08:00:00Z-3. Set confirmCreate=true. If you would otherwise create without confirmCreate=true, do not create and report that confirmation is required instead.
`);

    const call = turn.requireToolCall("create_x_drafts");
    t.check(call.input.confirmCreate, equals(true).gate());
    const drafts = call.input.drafts as readonly { idempotencyKey?: string }[];
    t.check(drafts.length === 3, equals(true).gate());
    const keys = new Set<string>();
    let allKeysUnique = true;
    for (const draft of drafts) {
      const key = draft.idempotencyKey;
      if (typeof key !== "string" || key.length === 0 || keys.has(key)) {
        allKeysUnique = false;
      }
      keys.add(key ?? "");
    }
    t.check(allKeysUnique, equals(true).gate());
    t.check(call.input.socialSetId === undefined, equals(true).soft());
    t.check(call.input.tag === undefined, equals(true).soft());
    t.check(call.input.madeWithAi === undefined, equals(true).soft());
    t.check(t.reply, includes("x-draft-assistant-2026-06-26T08:00:00Z-1").soft());
    const replyLower = (t.reply ?? "").toLowerCase();
    t.check(replyLower, includes("made with ai").soft());
  },
});

```

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

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

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

```

### `evals/failed-create-no-retry.eval.ts`

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

export default defineEval({
  description:
    "Reports a partially failed create_x_drafts result without retrying in the same step and without claiming every draft was created.",
  async test(t) {
    await t.send(`
The three previewed and approved drafts were submitted with create_x_drafts and the tool returned:

{
  "createdCount": 2,
  "replayedCount": 0,
  "failedCount": 1,
  "drafts": [
    { "idempotencyKey": "x-draft-assistant-2026-06-25T08:00:00Z-1", "created": true, "draftId": "d_101", "privateUrl": "https://typefully.com/drafts/d_101" },
    { "idempotencyKey": "x-draft-assistant-2026-06-25T08:00:00Z-2", "created": true, "draftId": "d_102", "privateUrl": "https://typefully.com/drafts/d_102" },
    { "idempotencyKey": "x-draft-assistant-2026-06-25T08:00:00Z-3", "created": false, "error": { "message": "Typefully API 429: rate limited", "status": 429 } }
  ]
}

Proceed according to your instructions: report the created drafts and the failure clearly, and do not retry create_x_drafts in this same step.
`);

    t.succeeded();
    t.noFailedActions();
    t.notCalledTool("create_x_drafts").gate();
    const replyLower = (t.reply ?? "").toLowerCase();
    t.check(
      replyLower.includes("429") || replyLower.includes("rate limit"),
      equals(true).gate(),
    );
    t.check(t.reply, includes("x-draft-assistant-2026-06-25T08:00:00Z-3").soft());
    t.check(t.reply, includes("d_101").soft());
  },
});

```

### `evals/missing-config-does-not-create.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 creating any Typefully drafts.",
  async test(t) {
    await t.send(`
Run the daily X hot topic Typefully drafts.

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 draft text, and do not call create_x_drafts or preview_x_draft. Report the missing configuration clearly.
`);

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

```

### `evals/x-draft-assistant.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 three X draft candidates without creating them in Typefully.",
  async test(t) {
    await t.send(`
Run the daily X hot topic Typefully drafts 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 draft exactly 3 distinct X post candidates and preview them with preview_x_draft. Do not call create_x_drafts in this run.
`);

    t.succeeded();
    t.noFailedActions();
    t.calledTool("research_hot_topics").gate();
    t.calledTool("preview_x_draft").gate();
    t.notCalledTool("create_x_drafts").gate();
    t.check(t.reply, includes("dryRun").soft());
    const replyLower = (t.reply ?? "").toLowerCase();
    t.check(replyLower, includes("made with ai").soft());
  },
});

```

### `agent/README.md`

````md
# X Draft Assistant

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 creates **three draft candidates** for X in [Typefully](https://typefully.com) so a human can review and publish them.

It runs on a cron schedule, reads only public posts via the X API v2, previews every draft in dry-run mode before creating anything for real, and never schedules or publishes the drafts.

## 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. **Draft three X post candidates** — writes exactly `X_HOT_TOPIC_DRAFT_COUNT` (default 3) distinct candidates from the researched topics. Each candidate is either a single tweet or a short thread (1-5 posts), each post at most 280 characters, each candidate a different angle on the same signal.
5. **Create drafts in Typefully** — previews every candidate with `preview_x_draft`, then creates them in Typefully through `create_x_drafts` only when `confirmCreate: true` and a stable, unique `idempotencyKey` per draft are provided. The idempotency key is held in an in-process cache and reused if Eve replays the step, so a retried create never produces a duplicate draft. If `X_HOT_TOPIC_DRAFT_TAG` references a tag that does not exist in the social set, the agent can list tags with `list_typefully_tags` and create it first with `create_typefully_tag` (gated on `confirmCreate: true`).

## Skills

- **typefully-best-practices** — X automation compliance, character limits, and the exactly-once draft creation model. Loaded before creating any X draft.
- **social** — social media content strategy: hook formulas, post templates, platform limits, short-form video structure, and social listening. Loaded before authoring X draft candidates so drafts follow engagement best practices.

## Installation

```bash
npx shadcn@latest add @evex/x-draft-assistant
```

## 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 run 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`. Clamped to the X API maximum of 100 and the minimum of 5; values above 100 are silently lowered to 100 rather than rejected, so a misconfigured run still returns posts instead of a 400.
- `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`.

### Draft candidates

- `X_HOT_TOPIC_DRAFT_COUNT` — number of distinct X draft candidates to produce per run. Defaults to `3`.
- `X_HOT_TOPIC_DRAFT_MADE_WITH_AI` — whether to label every X post with the X "made with AI" content disclosure. Defaults to `true` because the agent drafts posts with an LLM. Set to `false` only if a human rewrites the posts before publishing.
- `X_HOT_TOPIC_DRAFT_TAG` — optional Typefully tag slug to attach to every created draft. The tag must already exist in the social set, or the agent can list tags with `list_typefully_tags` and create it on demand with `create_typefully_tag`. Leave empty to skip tagging.

### Typefully credentials

- `TYPEFULLY_API_KEY` — Typefully API key from [typefully.com/?settings=api](https://typefully.com/?settings=api).
- `TYPEFULLY_SOCIAL_SET_ID` — the Typefully social set id (the account) to create drafts under. Find it by listing your social sets via the Typefully API, or copy it from the Typefully URL for the account you want to post to.

Creating drafts is a two-step, exactly-once-safe operation by design: the agent calls `preview_x_draft` first, then `create_x_drafts` with `confirmCreate: true` and a unique `idempotencyKey` per draft. The Typefully v2 API does not accept a server-side idempotency key, so the agent holds an in-process cache of successful creates keyed by the caller-provided idempotency key. A replayed Eve step with the same key returns the recorded response with `replayed: true` instead of issuing a second POST, so a retried create never duplicates a draft — as long as the replay happens in the same Node process. A replay that crosses a process boundary (serverless cold start, redeploy, restart) sees an empty cache and will POST again; a durable store (Redis, Postgres) would be needed to close that gap and is out of scope here. The recommended key is derived from the run's lookback window start (`scan_x_profiles` `windowStart`), so it is unique per run even when the schedule fires more than once a day, and stable across retries of the same run.

When `X_HOT_TOPIC_DRAFT_MADE_WITH_AI` is `true` (the default), every X post in every created draft is labeled with the X "made with AI" content disclosure, since the agent drafts posts with an LLM. Set it to `false` only if a human rewrites the posts before publishing.

### 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`, `TYPEFULLY_API_KEY`, `TYPEFULLY_SOCIAL_SET_ID`, 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-x-drafts
   ```

3. The agent should call `preview_x_draft` to review the three candidates. Creating is gated on `create_x_drafts` being called with `confirmCreate: true` and a unique `idempotencyKey` per draft, so a preview-only run creates nothing.
4. After the run, open Typefully for the configured social set: the three drafts should appear in `draft` status, not scheduled and not published.

## 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.
- **`authRequired: missingEnv TYPEFULLY_API_KEY`** — the Typefully API key is missing.
- **`notConfigured: missingEnv TYPEFULLY_SOCIAL_SET_ID`** — no social set configured. Set `TYPEFULLY_SOCIAL_SET_ID` to the Typefully account id you want to create drafts under.
- **`notConfirmed: true`** — `create_x_drafts` was called without `confirmCreate: true`. Review the preview first, then call it with the flag set.
- **`Duplicate idempotencyKey`** — two drafts in one `create_x_drafts` call shared a key. Each draft needs its own key (e.g. `x-draft-assistant-2026-06-26T08:00:00Z-1`, `-2`, `-3`).
- **`Typefully API 404` for the social set** — `TYPEFULLY_SOCIAL_SET_ID` points at a social set the API key cannot access. Confirm the id and that the key belongs to the same user or team.
- **`Typefully API 429`** — draft creation rate limit hit. Do not retry inside the same step; defer to a later run and reuse the same idempotency keys so a successful retry does not duplicate the drafts.
- **No drafts appear in Typefully** — the agent only creates drafts when `create_x_drafts` is called with `confirmCreate: true` and a unique `idempotencyKey` per draft. Confirm the run reached the create step and that `TYPEFULLY_SOCIAL_SET_ID` matches the account you are looking at.

## X automation compliance

The agent only creates drafts — it never publishes, schedules, replies, likes, or reposts. Each run produces at most three drafts with distinct text, never duplicates, never sets a reply target unless the user explicitly asks for a reply to a specific post, and labels every X post with the "made with AI" disclosure by default (configurable via `X_HOT_TOPIC_DRAFT_MADE_WITH_AI`) since the posts are drafted by an LLM. See the `typefully-best-practices` skill loaded by the agent for the full compliance model.

## 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_DRAFT_COUNT=3
X_HOT_TOPIC_DRAFT_MADE_WITH_AI=true
X_HOT_TOPIC_DRAFT_TAG=

PARALLEL_API_KEY=
TYPEFULLY_API_KEY=
TYPEFULLY_SOCIAL_SET_ID=

```
