{"$schema":"https://ui.shadcn.com/schema/registry.json","name":"x-draft-assistant","type":"registry:item","title":"X Draft Assistant","description":"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.","author":"TommyBez","categories":["general"],"dependencies":["ai@^7.0.38","eve@^0.31.3","parallel-web@^1.1.0","zod@4.3.6"],"meta":{"slug":"x-draft-assistant","category":"general","createdAt":"2026-06-26T16:19:57.402Z","updatedAt":"2026-07-04T00:00:00.000Z","docs":{"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."],"howItWorks":["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.","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.","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.","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.","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.","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."],"useCases":[{"title":"Daily content pipeline for a startup account","body":"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."},{"title":"Riding launch and announcement waves","body":"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."},{"title":"Developer relations topic monitoring","body":"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."},{"title":"Compliance-safe AI drafting","body":"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":[{"name":"X_BEARER_TOKEN","body":"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."},{"name":"X_HOT_TOPIC_HANDLES","body":"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."},{"name":"PARALLEL_API_KEY","body":"API key for the Parallel Search API, used to research each hot topic with ranked web sources. Get one at platform.parallel.ai."},{"name":"TYPEFULLY_API_KEY","body":"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."},{"name":"TYPEFULLY_SOCIAL_SET_ID","body":"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."},{"name":"X_HOT_TOPIC_DAILY_CRON","body":"Optional 5-field cron expression controlling when the daily run fires, evaluated in UTC on Vercel. Defaults to 0 8 * * * (08:00 UTC daily)."},{"name":"X_HOT_TOPIC_DRAFT_TAG","body":"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."}],"faqs":[{"question":"How do I install and run it?","answer":"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."},{"question":"Can it publish or schedule posts on X?","answer":"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."},{"question":"Which model does the agent use?","answer":"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."},{"question":"How does it avoid creating duplicate drafts?","answer":"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."},{"question":"What limits and quotas should I know about?","answer":"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":[{"path":"agent/agent.ts","type":"registry:file","target":"~/agent/agent.ts","content":"import { defineAgent } from \"eve\";\n\nexport default defineAgent({\n  model: \"deepseek/deepseek-v4-flash\",\n});\n"},{"path":"agent/instructions.md","type":"registry:file","target":"~/agent/instructions.md","content":"# Mission\nProduce three X (Twitter) draft candidates every day from hot topics surfaced on a\nwatched set of profiles, researched with the Parallel web search API, and created\nas drafts in Typefully for a human to review and publish.\n\n# Workflow\n1. Load the typefully-best-practices skill before drafting or creating any X\n   draft. The skill encodes X automation compliance, character limits, and the\n   exactly-once draft creation model.\n2. Load the social skill before authoring X draft candidates. It provides hook\n   formulas, post templates, platform limits, and angle-diversity rules for\n   the three-candidate X draft workflow.\n3. Use scan_x_profiles to pull recent posts from every configured handle, scoped\n   to the last `X_HOT_TOPIC_LOOKBACK_HOURS` (default 24). If no handles are\n   configured, stop and report the missing configuration instead of inventing\n   profiles. Only treat posts inside the lookback window as hot-topic candidates,\n   so the drafts do not repeat the same topics day over day.\n3. From the returned posts, surface up to `X_HOT_TOPIC_MAX_TOPICS` hot topics. A\n   hot topic is a recurring theme, announcement, launch, debate, or signal that\n   appears across posts or that carries outsized engagement for a profile.\n   Cluster near-duplicates into a single topic.\n4. For each hot topic, use research_hot_topics with 2-3 focused keyword queries\n   to gather ranked web sources with provenance. Skip research for topics that\n   are too vague to query.\n5. Draft exactly `X_HOT_TOPIC_DRAFT_COUNT` (default 3) distinct X post candidates\n   from the researched topics. Each candidate is either a single tweet or a short\n   thread (1-5 posts). Candidates must differ in angle, tone, or length — not\n   just rearranged words — so the user has a real choice. Respect the 280-char X\n   limit per post. Cite originating X posts as\n   `https://x.com/<handle>/status/<id>` only with handles and ids returned by\n   scan_x_profiles. Do not fabricate URLs, post ids, or quotes.\n6. Always call preview_x_draft first to review the exact drafts, post lengths,\n   target social set, tag, and madeWithAi flag. The social set id, tag, and\n   madeWithAi flag come from `TYPEFULLY_SOCIAL_SET_ID`, `X_HOT_TOPIC_DRAFT_TAG`,\n   and `X_HOT_TOPIC_DRAFT_MADE_WITH_AI` and cannot be overridden through tool\n   input — never try to pass `socialSetId`, `tag`, or `madeWithAi` to the create\n   tool. The made-with-AI label defaults to true because these posts are drafted\n   by an LLM; only disable it if a human rewrites the posts before publishing.\n   If `X_HOT_TOPIC_DRAFT_TAG` names a tag that does not yet exist in the social\n   set, call list_typefully_tags first to check whether the tag already exists\n   under a different name or slug, then call create_typefully_tag with\n   `confirmCreate: true` to create it before creating drafts. Only create a tag\n   when it is genuinely missing — reuse an existing tag whenever possible.\n7. To create the drafts in Typefully, call create_x_drafts with `confirmCreate:\n   true` and a stable, unique `idempotencyKey` per draft. The recommended scheme\n   is `x-draft-assistant-<windowStartUtc>-<n>`, where `<windowStartUtc>` is the\n   `windowStart` value returned by scan_x_profiles (the RFC3339 UTC start of\n   this run's lookback window) and `<n>` is the 1-based index of the draft\n   candidate within the run. Using the lookback window start makes the key\n   unique per run even when the schedule fires more than once a day, and stable\n   across retries of the same run. Reuse the same key if the step is retried so\n   a replayed create does not duplicate the draft. Never call create_x_drafts\n   without an idempotencyKey per draft, and never reuse the same key across two\n   drafts in one call. If create_x_drafts returns a draft with `created: false`\n   and an `error`, report the error and do not retry inside the same step.\n\n# Output contract\nReturn:\n- the list of hot topics with origin posts and research sources\n- the three X draft candidates (title, posts, scratchpad) as previewed by\n  preview_x_draft\n- the create result from create_x_drafts when it was called, including each\n  draft's idempotencyKey, draftId, and private_url\n- any missing configuration that blocked a step\n\n# Guardrails\n- Do not publish or schedule drafts in Typefully. The agent only creates drafts.\n- Do not disable the X \"made with AI\" disclosure unless a human rewrites the\n  posts before publishing. The posts are drafted by an LLM, so the label is\n  required by X's content disclosure policy.\n- Do not set a reply target on a draft unless the user explicitly asked for a\n  reply to a specific post.\n- Do not duplicate text across the three candidates in one run.\n- Do not fabricate URLs, excerpts, or post ids. Every citation must come from a\n  tool result.\n- Do not retry a failed create_x_drafts call inside the same Eve step.\n- If a tool reports `authRequired` or `notConfigured`, stop and report it instead\n  of proceeding.\n"},{"path":"agent/lib/hot-topic-config.ts","type":"registry:file","target":"~/agent/lib/hot-topic-config.ts","content":"export type HotTopicConfig = {\n  readonly handles: readonly string[];\n  readonly dailyCron: string;\n  readonly lookbackHours: number;\n  readonly maxTweetsPerProfile: number;\n  readonly maxHotTopics: number;\n  readonly searchMaxResults: number;\n  readonly searchMode: \"turbo\" | \"basic\" | \"advanced\";\n  readonly draft: {\n    readonly count: number;\n    readonly madeWithAi: boolean;\n    readonly tag?: string;\n    readonly socialSetId?: string;\n  };\n};\n\nconst DEFAULT_MAX_TWEETS_PER_PROFILE = 20;\nconst DEFAULT_MAX_HOT_TOPICS = 5;\nconst DEFAULT_SEARCH_MAX_RESULTS = 5;\nconst DEFAULT_SEARCH_MODE = \"basic\";\nconst DEFAULT_DAILY_CRON = \"0 8 * * *\";\nconst DEFAULT_LOOKBACK_HOURS = 24;\nconst DEFAULT_DRAFT_COUNT = 3;\nconst DEFAULT_DRAFT_MADE_WITH_AI = true;\n\nconst compactCsv = (value: string | undefined): string[] =>\n  (value ?? \"\")\n    .split(\",\")\n    .map((item) => item.trim())\n    .filter(Boolean);\n\nconst optional = (value: string | undefined): string | undefined => {\n  const trimmed = value?.trim();\n  return trimmed ? trimmed : undefined;\n};\n\nconst parsePositiveInteger = (value: string | undefined, fallback: number): number => {\n  const parsed = Number.parseInt(value ?? \"\", 10);\n  return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;\n};\n\nconst parseSearchMode = (value: string | undefined): \"turbo\" | \"basic\" | \"advanced\" => {\n  const trimmed = value?.trim().toLowerCase();\n  if (trimmed === \"turbo\" || trimmed === \"basic\" || trimmed === \"advanced\") {\n    return trimmed;\n  }\n  return DEFAULT_SEARCH_MODE;\n};\n\nconst parseBoolean = (value: string | undefined, fallback: boolean): boolean => {\n  const trimmed = value?.trim().toLowerCase();\n  if (trimmed === \"true\" || trimmed === \"1\" || trimmed === \"yes\" || trimmed === \"on\") {\n    return true;\n  }\n  if (trimmed === \"false\" || trimmed === \"0\" || trimmed === \"no\" || trimmed === \"off\") {\n    return false;\n  }\n  return fallback;\n};\n\nconst toRfc3339Utc = (date: Date): string =>\n  date.toISOString().replace(/\\.\\d{3}Z$/, \"Z\");\n\nexport const getLookbackStartTime = (now: Date = new Date()): string =>\n  toRfc3339Utc(new Date(now.getTime() - hotTopicConfig.lookbackHours * 60 * 60 * 1000));\n\nexport const hotTopicConfig = {\n  handles: compactCsv(process.env.X_HOT_TOPIC_HANDLES),\n  dailyCron: optional(process.env.X_HOT_TOPIC_DAILY_CRON) ?? DEFAULT_DAILY_CRON,\n  lookbackHours: parsePositiveInteger(\n    process.env.X_HOT_TOPIC_LOOKBACK_HOURS,\n    DEFAULT_LOOKBACK_HOURS,\n  ),\n  maxTweetsPerProfile: parsePositiveInteger(\n    process.env.X_HOT_TOPIC_MAX_TWEETS_PER_PROFILE,\n    DEFAULT_MAX_TWEETS_PER_PROFILE,\n  ),\n  maxHotTopics: parsePositiveInteger(\n    process.env.X_HOT_TOPIC_MAX_TOPICS,\n    DEFAULT_MAX_HOT_TOPICS,\n  ),\n  searchMaxResults: parsePositiveInteger(\n    process.env.X_HOT_TOPIC_SEARCH_MAX_RESULTS,\n    DEFAULT_SEARCH_MAX_RESULTS,\n  ),\n  searchMode: parseSearchMode(process.env.X_HOT_TOPIC_SEARCH_MODE),\n  draft: {\n    count: parsePositiveInteger(\n      process.env.X_HOT_TOPIC_DRAFT_COUNT,\n      DEFAULT_DRAFT_COUNT,\n    ),\n    madeWithAi: parseBoolean(\n      process.env.X_HOT_TOPIC_DRAFT_MADE_WITH_AI,\n      DEFAULT_DRAFT_MADE_WITH_AI,\n    ),\n    tag: optional(process.env.X_HOT_TOPIC_DRAFT_TAG),\n    socialSetId: optional(process.env.TYPEFULLY_SOCIAL_SET_ID),\n  },\n} satisfies HotTopicConfig;\n"},{"path":"agent/lib/typefully-client.ts","type":"registry:file","target":"~/agent/lib/typefully-client.ts","content":"// Typefully Public API v2 client. Minimal surface for creating X drafts.\n// Reference: https://typefully.com/docs/api\n\nconst TYPEFULLY_API_BASE = \"https://api.typefully.com\";\n\nexport type TypefullyXPost = {\n  readonly text: string;\n  readonly madeWithAi?: boolean;\n};\n\nexport type TypefullyCreateDraftInput = {\n  readonly socialSetId: string;\n  readonly posts: readonly TypefullyXPost[];\n  readonly draftTitle?: string;\n  readonly scratchpad?: string;\n  readonly tags?: readonly string[];\n};\n\nexport type TypefullyCreateDraftResponse = {\n  readonly id: number;\n  readonly social_set_id: number;\n  readonly status: string;\n  readonly preview: string;\n  readonly private_url: string;\n  readonly share_url?: string | null;\n  readonly draft_title?: string | null;\n  readonly scheduled_date?: string | null;\n  readonly created_at: string;\n};\n\nexport type TypefullyTagResponse = {\n  readonly id: number;\n  readonly name: string;\n  readonly slug?: string | null;\n  readonly social_set_id?: number | null;\n};\n\nexport type TypefullyCreateTagInput = {\n  readonly socialSetId: string;\n  readonly name: string;\n};\n\nexport type TypefullyListTagsResponse = {\n  readonly total?: number;\n  readonly results?: readonly TypefullyTagResponse[];\n  readonly items?: readonly TypefullyTagResponse[];\n  readonly data?: readonly TypefullyTagResponse[];\n};\n\nexport type TypefullyError = {\n  readonly message: string;\n  readonly status: number;\n  readonly body: string;\n};\n\nexport class TypefullyApiError extends Error {\n  readonly status: number;\n  readonly body: string;\n  constructor(error: TypefullyError) {\n    super(error.message);\n    this.name = \"TypefullyApiError\";\n    this.status = error.status;\n    this.body = error.body;\n  }\n}\n\ntype TypefullyErrorBody = {\n  readonly error?: {\n    readonly code?: string;\n    readonly message?: string;\n    readonly details?: readonly {\n      readonly message?: string;\n      readonly field?: string;\n    }[];\n  };\n};\n\nfunction summarizeErrorBody(body: string, status: number): string {\n  if (!body) {\n    return `Typefully API ${status} with no response body.`;\n  }\n  try {\n    const parsed = JSON.parse(body) as TypefullyErrorBody;\n    const top = parsed.error?.message;\n    if (top) {\n      return `Typefully API ${status}: ${top}`;\n    }\n  } catch {\n    // Fall through to the raw slice.\n  }\n  return `Typefully API ${status}: ${body.slice(0, 500)}`;\n}\n\nexport async function createTypefullyDraft(\n  input: TypefullyCreateDraftInput,\n  apiKey: string,\n): Promise<TypefullyCreateDraftResponse> {\n  const payload = {\n    platforms: {\n      x: {\n        enabled: true,\n        posts: input.posts.map((post) => ({\n          text: post.text,\n          ...(post.madeWithAi ? { made_with_ai: true } : {}),\n        })),\n        settings: {},\n      },\n    },\n    draft_title: input.draftTitle,\n    scratchpad_text: input.scratchpad,\n    tags: input.tags,\n    share: false,\n  };\n\n  const response = await fetch(\n    `${TYPEFULLY_API_BASE}/v2/social-sets/${encodeURIComponent(input.socialSetId)}/drafts`,\n    {\n      method: \"POST\",\n      headers: {\n        Authorization: `Bearer ${apiKey}`,\n        \"Content-Type\": \"application/json\",\n      },\n      body: JSON.stringify(payload),\n    },\n  );\n\n  const responseText = await response.text();\n  if (!response.ok) {\n    throw new TypefullyApiError({\n      message: summarizeErrorBody(responseText, response.status),\n      status: response.status,\n      body: responseText,\n    });\n  }\n\n  const data = JSON.parse(responseText) as TypefullyCreateDraftResponse;\n  return {\n    ...data,\n    draft_title: data.draft_title ?? input.draftTitle ?? null,\n  };\n}\n\nexport async function createTypefullyTag(\n  input: TypefullyCreateTagInput,\n  apiKey: string,\n): Promise<TypefullyTagResponse> {\n  const response = await fetch(\n    `${TYPEFULLY_API_BASE}/v2/social-sets/${encodeURIComponent(input.socialSetId)}/tags`,\n    {\n      method: \"POST\",\n      headers: {\n        Authorization: `Bearer ${apiKey}`,\n        \"Content-Type\": \"application/json\",\n      },\n      body: JSON.stringify({ name: input.name }),\n    },\n  );\n\n  const responseText = await response.text();\n  if (!response.ok) {\n    throw new TypefullyApiError({\n      message: summarizeErrorBody(responseText, response.status),\n      status: response.status,\n      body: responseText,\n    });\n  }\n\n  return JSON.parse(responseText) as TypefullyTagResponse;\n}\n\nexport async function listTypefullyTags(\n  socialSetId: string,\n  apiKey: string,\n): Promise<readonly TypefullyTagResponse[]> {\n  const response = await fetch(\n    `${TYPEFULLY_API_BASE}/v2/social-sets/${encodeURIComponent(socialSetId)}/tags?limit=50`,\n    {\n      method: \"GET\",\n      headers: {\n        Authorization: `Bearer ${apiKey}`,\n        \"Content-Type\": \"application/json\",\n      },\n    },\n  );\n\n  const responseText = await response.text();\n  if (!response.ok) {\n    throw new TypefullyApiError({\n      message: summarizeErrorBody(responseText, response.status),\n      status: response.status,\n      body: responseText,\n    });\n  }\n\n  const parsed = JSON.parse(responseText) as\n    | TypefullyListTagsResponse\n    | TypefullyTagResponse[];\n  if (Array.isArray(parsed)) {\n    return parsed;\n  }\n  return parsed.results ?? parsed.items ?? parsed.data ?? [];\n}\n"},{"path":"agent/schedules/daily-x-drafts.ts","type":"registry:file","target":"~/agent/schedules/daily-x-drafts.ts","content":"import { defineSchedule } from \"eve/schedules\";\n\nimport { hotTopicConfig } from \"../lib/hot-topic-config.js\";\n\nexport default defineSchedule({\n  cron: hotTopicConfig.dailyCron,\n  markdown: `Run the daily X draft assistant.\n\n1. 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.\n2. Surface up to ${hotTopicConfig.maxHotTopics} hot topics from those posts.\n3. For each topic, call research_hot_topics with focused keyword queries.\n4. 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.\n5. 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).\n6. 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.\n\nIf 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.`,\n});\n"},{"path":"agent/skills/social/references/platform-limits.md","type":"registry:file","target":"~/agent/skills/social/references/platform-limits.md","content":"# X (Twitter) limits\n\n| Element | Limit |\n|---------|-------|\n| Max post chars | 280 |\n| Thread length | 1–5 posts for this agent's draft candidates |\n| Visible before \"more\" | ~280 (single post) |\n| Link handling | URLs count toward character limit |\n\nFor hook formulas and post templates, see [post-templates](./post-templates.md).\n"},{"path":"agent/skills/social/references/post-templates.md","type":"registry:file","target":"~/agent/skills/social/references/post-templates.md","content":"# Post Format Templates\n\nReady-to-use templates for different platforms and content types.\n\n## Contents\n- LinkedIn Post Templates (The Story Post, The Contrarian Take, The List Post, The How-To)\n- Twitter/X Thread Templates (The Tutorial Thread, The Story Thread, The Breakdown Thread)\n- Instagram Templates (The Carousel Hook, The Reel Script)\n- Hook Formulas (Curiosity Hooks, Story Hooks, Value Hooks, Contrarian Hooks, Social Proof Hooks)\n\n## LinkedIn Post Templates\n\n### The Story Post\n```\n[Hook: Unexpected outcome or lesson]\n\n[Set the scene: When/where this happened]\n\n[The challenge you faced]\n\n[What you tried / what happened]\n\n[The turning point]\n\n[The result]\n\n[The lesson for readers]\n\n[Question to prompt engagement]\n```\n\n### The Contrarian Take\n```\n[Unpopular opinion stated boldly]\n\nHere's why:\n\n[Reason 1]\n[Reason 2]\n[Reason 3]\n\n[What you recommend instead]\n\n[Invite discussion: \"Am I wrong?\"]\n```\n\n### The List Post\n```\n[X things I learned about [topic] after [credibility builder]:\n\n1. [Point] — [Brief explanation]\n\n2. [Point] — [Brief explanation]\n\n3. [Point] — [Brief explanation]\n\n[Wrap-up insight]\n\nWhich resonates most with you?\n```\n\n### The How-To\n```\nHow to [achieve outcome] in [timeframe]:\n\nStep 1: [Action]\n↳ [Why this matters]\n\nStep 2: [Action]\n↳ [Key detail]\n\nStep 3: [Action]\n↳ [Common mistake to avoid]\n\n[Result you can expect]\n\n[CTA or question]\n```\n\n---\n\n## Twitter/X Thread Templates\n\n### The Tutorial Thread\n```\nTweet 1: [Hook + promise of value]\n\n\"Here's exactly how to [outcome] (step-by-step):\"\n\nTweet 2-7: [One step per tweet with details]\n\nFinal tweet: [Summary + CTA]\n\n\"If this was helpful, follow me for more on [topic]\"\n```\n\n### The Story Thread\n```\nTweet 1: [Intriguing hook]\n\n\"[Time] ago, [unexpected thing happened]. Here's the full story:\"\n\nTweet 2-6: [Story beats, building tension]\n\nTweet 7: [Resolution and lesson]\n\nFinal tweet: [Takeaway + engagement ask]\n```\n\n### The Breakdown Thread\n```\nTweet 1: [Company/person] just [did thing].\n\nHere's why it's genius (and what you can learn):\n\nTweet 2-6: [Analysis points]\n\nTweet 7: [Your key takeaway]\n\n\"[Related insight + follow CTA]\"\n```\n\n---\n\n## Instagram Templates\n\n### The Carousel Hook\n```\n[Slide 1: Bold statement or question]\n[Slides 2-9: One point per slide, visual + text]\n[Slide 10: Summary + CTA]\n\nCaption: [Expand on the topic, add context, include CTA]\n```\n\n### The Reel Script\n```\nHook (0-2 sec): [Pattern interrupt or bold claim]\nSetup (2-5 sec): [Context for the tip]\nValue (5-25 sec): [The actual advice/content]\nCTA (25-30 sec): [Follow, comment, share, link]\n```\n\n---\n\n## Hook Formulas\n\nThe first line determines whether anyone reads the rest.\n\n### Curiosity Hooks\n- \"I was wrong about [common belief].\"\n- \"The real reason [outcome] happens isn't what you think.\"\n- \"[Impressive result] — and it only took [surprisingly short time].\"\n- \"Nobody talks about [insider knowledge].\"\n\n### Story Hooks\n- \"Last week, [unexpected thing] happened.\"\n- \"I almost [big mistake/failure].\"\n- \"3 years ago, I [past state]. Today, [current state].\"\n- \"[Person] told me something I'll never forget.\"\n\n### Value Hooks\n- \"How to [desirable outcome] (without [common pain]):\"\n- \"[Number] [things] that [outcome]:\"\n- \"The simplest way to [outcome]:\"\n- \"Stop [common mistake]. Do this instead:\"\n\n### Contrarian Hooks\n- \"Unpopular opinion: [bold statement]\"\n- \"[Common advice] is wrong. Here's why:\"\n- \"I stopped [common practice] and [positive result].\"\n- \"Everyone says [X]. The truth is [Y].\"\n\n### Social Proof Hooks\n- \"We [achieved result] in [timeframe]. Here's the full story:\"\n- \"[Number] people asked me about [topic]. Here's my answer:\"\n- \"[Authority figure] taught me [lesson].\"\n"},{"path":"agent/skills/social/SKILL.md","type":"registry:file","target":"~/agent/skills/social/SKILL.md","content":"---\nname: social\ndescription: Draft X posts with strong hooks, distinct angles, and scroll-stopping structure for the three-candidate workflow.\n---\n\n# X drafting\n\nUse when authoring X draft candidates. This agent produces three distinct posts or\nshort threads per run — not multi-platform social strategy.\n\n## Hooks\n\nThe first line determines whether anyone reads the rest.\n\n### Curiosity\n\n- \"I was wrong about [common belief].\"\n- \"The real reason [outcome] happens isn't what you think.\"\n- \"[Impressive result] — and it only took [surprisingly short time].\"\n\n### Story\n\n- \"Last week, [unexpected thing] happened.\"\n- \"I almost [big mistake/failure].\"\n- \"3 years ago, I [past state]. Today, [current state].\"\n\n### Value\n\n- \"How to [desirable outcome] (without [common pain]):\"\n- \"[Number] [things] that [outcome]:\"\n- \"Stop [common mistake]. Do this instead:\"\n\n### Contrarian\n\n- \"Unpopular opinion: [bold statement]\"\n- \"[Common advice] is wrong. Here's why:\"\n- \"I stopped [common practice] and [positive result].\"\n\nFor more hook and post templates, see [post-templates](./references/post-templates.md).\n\n## Draft rules\n\n- Each candidate must differ in **angle, tone, or length** — not rearranged words.\n- Lead with the takeaway. Threads read top-to-bottom; later posts add evidence or\n  nuance.\n- One idea per post. Stay within the 280-character limit per post.\n- Cite originating posts only with handles and ids from `scan_x_profiles`.\n- Do not fabricate URLs, quotes, or engagement claims.\n\nFor character limits, see [platform-limits](./references/platform-limits.md).\n\n## Angle diversity\n\nWhen drafting three candidates from one hot topic, vary at least two of:\n\n- **Stance** — support, challenge, or add nuance to the signal\n- **Format** — single tweet vs short thread\n- **Framing** — practitioner takeaway, contrarian read, or \"what this means next\"\n\n**Done when** each candidate would make sense as the only draft in the run.\n"},{"path":"agent/skills/typefully-best-practices/references/exactly-once.md","type":"registry:file","target":"~/agent/skills/typefully-best-practices/references/exactly-once.md","content":"# Exactly Once\n\nEnsuring a Typefully draft is created exactly once across Eve step replays.\n\n## The problem\n\nEve replays completed steps from their recorded result, but a step interrupted\nmid-execution re-runs. If a `create_x_drafts` call is interrupted after the\nTypefully POST succeeds but before the result is recorded, a replay issues a\nsecond POST and creates a duplicate draft.\n\nThe Typefully v2 API does not accept a server-side idempotency key, so the\ndefense is in-process: a per-draft `idempotencyKey` plus a cache of successful\ncreates keyed by that key.\n\n### Scope of the in-process cache\n\nThe cache lives in the Node process that ran the create. It protects against\nEve step replays inside that process (the common case: a step interrupted\nmid-execution re-runs in the same session). It does **not** protect against\nreplays that cross a process boundary — a serverless cold start, a redeploy,\nor a process restart will see an empty cache and issue a second POST if Eve\nreplays the step there. A durable store (Redis, Postgres, or another\nshared-state backend) would be needed to close that gap, and is out of scope\nfor this agent. The mitigations in place are:\n\n- The recommended `idempotencyKey` is derived from the run's lookback window\n  start, so a re-triggered run with the same window reuses the same key — but\n  only the in-process cache can short-circuit it.\n- `confirmCreate` must be `true` before any POST goes out, so accidental\n  creates are gated.\n- The agent never publishes or schedules drafts, so a duplicate draft is\n  reviewable noise in Typefully, not a public double-post.\n\n## Solution: per-draft idempotency keys\n\nEach draft in a `create_x_drafts` call carries a stable `idempotencyKey`. Before\nissuing a POST, the tool checks the cache:\n\n- A hit returns the recorded response with `replayed: true` and never issues a\n  second POST.\n- A miss issues the POST, then stores the response on success. Failures are not\n  cached, so the same key can be retried on a later run.\n\n### Key generation strategies\n\n| Strategy | Example | Use when |\n|----------|---------|----------|\n| 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 |\n| Run + topic slug | `x-draft-assistant-2026-06-26T08:00:00Z-ai-sdk-5` | Stable across topic reordering within a run |\n| UUID | `crypto.randomUUID()` | No natural key (generate once, reuse on retry) |\n\n**Best practice:** use deterministic keys based on the run's lookback window\nstart (returned by `scan_x_profiles` as `windowStart`) and the candidate index.\nThe window start is unique per run even when the schedule fires more than once\na day, and is stable across retries of the same run. If the same logical create\nis retried, the same key must be regenerated. Avoid `Date.now()` or random\nvalues generated fresh on each attempt — a fresh value per attempt breaks\nexactly-once.\n\n### Duplicate keys inside one call\n\nEach draft in a single `create_x_drafts` call must have a unique\n`idempotencyKey`. The tool rejects a call with duplicate keys before any POST is\nissued, so a misconfigured run cannot create one draft and silently drop another.\n\n## Result shape: distinguish `created`, `replayed`, and failures\n\nThe tool returns one entry per draft, tagged so the caller can tell them apart:\n\n```typescript\ntype DraftResult =\n  | { created: true; draftId; privateUrl; ... }\n  | { replayed: true; draftId; privateUrl; ... }\n  | { created: false; error: { message; status? } };\n```\n\nA `replayed` entry is a success — the draft already exists and the replay did not\nduplicate it. A `created: false` entry is a failure that can be retried with the\nsame key on a later run.\n\n## Retry logic\n\nA failed create should not be retried inside the same Eve step. The Typefully\nper-social-set rate limit on `drafts.create` is small, and a tight retry loop\nwill burn through it. Surface the failure in the output and let the user retry on\na later run, reusing the same `idempotencyKey` so a successful retry does not\nduplicate the draft.\n\n| Error type | Retry? | Notes |\n|------------|--------|-------|\n| 429 (rate limit) | No, defer to a later run | Wait for the rate limit window |\n| 5xx (server error) | Yes, on a later run | Transient, likely to resolve |\n| 4xx (client error) | No | Fix the request first |\n| Network timeout | Yes, on a later run | Transient |\n\n## The `confirmCreate` guard\n\n`create_x_drafts` requires `confirmCreate: true` before it issues any POST. This\nis a separate guard from the idempotency key: the key makes replays safe, the\nflag makes accidental creates impossible. Always call `preview_x_draft` first,\nreview the candidates, then call `create_x_drafts` with the flag set.\n\n## Related\n\n- [X Automation](./x-automation.md) — content and engagement rules for X drafts\n"},{"path":"agent/skills/typefully-best-practices/references/x-automation.md","type":"registry:file","target":"~/agent/skills/typefully-best-practices/references/x-automation.md","content":"# X Automation Compliance\n\nX's automation rules govern anything posted through the Typefully API on behalf of\nan account. The agent only creates drafts — it never publishes or schedules — but\nthe same rules govern the content that lands in the queue.\n\n## Rules\n\n### No duplicate content across drafts in the same run\n\nEach of the three draft candidates must take a distinct angle on the same hot\ntopic. Reusing the same text across drafts risks an X duplicate-content flag and\nreduces the value of offering the user three options.\n\n### No unsolicited automated replies\n\nNever set a reply target on a draft unless the user explicitly asked for a reply\nto a specific post. The agent creates top-level posts only. Replying to\nunrelated accounts is one of the fastest ways to get an account flagged.\n\n### No trending manipulation\n\nDo not stuff hashtags or pile onto a trending topic to game visibility. The\ndrafts react to a real signal from watched profiles, not to the trending tab. If\na topic is genuinely trending, write about it for its substance, not for the\ntrend.\n\n### No fake engagement\n\nThe agent does not like, repost, follow, or reply. It only creates drafts. Do\nnot add engagement-style framing (\"boost this\", \"retweet if you agree\") to draft\ntext either.\n\n### Label AI-drafted posts\n\nX's content disclosure policy requires a \"made with AI\" label on posts generated\nby an LLM. The agent drafts posts with a model, so every X post is created with\n`made_with_ai: true` by default. `X_HOT_TOPIC_DRAFT_MADE_WITH_AI` controls the\nflag and defaults to `true`.\n\nOnly disable the label (`X_HOT_TOPIC_DRAFT_MADE_WITH_AI=false`) if a human\nrewrites the posts before publishing. Disabling it for AI-drafted content\nviolates X's content disclosure policy and risks account enforcement.\n\n### Respect rate limits\n\nThe Typefully API rate-limits draft creation per user and per social set. One\nrun produces at most three drafts; do not loop create calls to retry a failed\ndraft in the same step. If a draft fails, surface the error in the output and let\nthe user retry on a later run.\n\n## Priority order\n\nWhen you cannot satisfy every rule, fix in this order:\n\n1. Missing \"made with AI\" label on AI-drafted posts (policy violation, account\n   enforcement risk).\n2. Duplicate content across the three drafts in the same run.\n3. Unsolicited reply target on a draft.\n4. Hashtag stuffing or trending manipulation.\n5. Engagement-bait framing in the post text.\n6. Retrying a failed create in the same step.\n\n## Authoring checklist\n\n- [ ] `X_HOT_TOPIC_DRAFT_MADE_WITH_AI` is `true` (default) unless a human rewrites the posts before publishing\n- [ ] Each of the three draft candidates has distinct text and a distinct angle\n- [ ] No draft sets a reply target unless the user explicitly asked for a reply\n- [ ] No hashtag stuffing, no engagement bait, no trending manipulation\n- [ ] No retry loop on a failed `create_x_drafts` call inside one step\n\n## Related\n\n- [Exactly Once](./exactly-once.md) — idempotent draft creation and replay safety\n"},{"path":"agent/skills/typefully-best-practices/SKILL.md","type":"registry:file","target":"~/agent/skills/typefully-best-practices/SKILL.md","content":"---\nname: typefully-best-practices\ndescription: Draft X posts through Typefully with automation compliance, character limits, and exactly-once draft creation.\n---\n\nGuidance for drafting X posts and creating Typefully drafts without violating X's\nautomation rules or producing duplicate drafts. Apply these rules whenever an X\ndraft is being authored or created through Typefully.\n\n## X automation compliance\n\nX's automation rules apply to anything posted through the Typefully API on behalf\nof an account. The agent only creates drafts; it never publishes or schedules\nthem, but the same rules govern the content that lands in the queue.\n\n- **No duplicate content across drafts in the same run.** Each of the three draft\n  candidates must take a distinct angle on the same hot topic. Reusing the same\n  text across drafts risks an X duplicate-content flag.\n- **No unsolicited automated replies.** Never set a reply target on a draft\n  unless the user explicitly asked for a reply to a specific post. The agent\n  creates top-level posts only.\n- **No trending manipulation.** Do not stuff hashtags or pile onto a trending\n  topic to game visibility. The drafts react to a real signal from watched\n  profiles, not to the trending tab.\n- **No fake engagement.** The agent does not like, repost, follow, or reply. It\n  only creates drafts.\n- **Label AI-drafted posts.** X requires a \"made with AI\" label on LLM-generated\n  posts. `X_HOT_TOPIC_DRAFT_MADE_WITH_AI` defaults to `true` and\n  `create_x_drafts` sets `made_with_ai: true` on every X post. Only disable the\n  label if a human rewrites the posts before publishing.\n- **Respect rate limits.** The Typefully API rate-limits draft creation per user\n  and per social set. One run produces at most three drafts; do not loop create\n  calls to retry a failed draft in the same step.\n\nSee [x-automation](./references/x-automation.md) for the full compliance model.\n\n## Exactly-once draft creation\n\nThe Typefully v2 API does not accept a server-side idempotency key, so a replayed\nEve step would normally create a second draft. The agent defends against that\nwith a per-draft `idempotencyKey` plus an in-process cache of successful\ncreates:\n\n- Derive each key from the run, not from `Date.now()` or a fresh random value.\n  A stable scheme is `x-draft-assistant-<windowStartUtc>-<n>`, where\n  `<windowStartUtc>` is the `windowStart` value returned by `scan_x_profiles`\n  (RFC3339 UTC start of this run's lookback window) and `<n>` is the 1-based index\n  of the draft candidate within the run. Anchoring to the lookback window start\n  makes the key unique per run even when the schedule fires more than once a\n  day, and stable across retries of the same run.\n- Each draft in a single `create_x_drafts` call must have a unique key. Duplicate\n  keys inside one call are rejected before any POST is issued.\n- A replayed step with the same key returns the recorded response instead of\n  issuing a second POST. Failures are not cached, so the same key can be retried.\n- `confirmCreate` must be `true` before any draft is created. Treat it as a\n  guardrail: always call `preview_x_draft` first, then create with the flag set.\n\nSee [exactly-once](./references/exactly-once.md) for the idempotency and retry\nmodel in detail.\n\n## Drafting for X\n\nX posts are short, single-purpose, and easy to read in a fast scroll.\n\n- Each post is at most 280 characters. `preview_x_draft` validates this; longer\n  posts are rejected before any network call.\n- A single-post draft is a tweet. A multi-post draft is a thread: order posts so\n  the thread reads top-to-bottom, lead with the takeaway, and let later posts add\n  evidence or nuance.\n- Keep drafts distinct: the three candidates should differ in angle, length, or\n  tone — not just rearranged words.\n- Cite the originating X post when its content anchors the draft. Link as\n  `https://x.com/<handle>/status/<id>` and only use handles and ids returned by\n  `scan_x_profiles`.\n- Do not fabricate URLs, post ids, or quotes. Every citation must come from a\n  tool result.\n\n## Output discipline\n\nThe agent creates drafts only. It never schedules, publishes, or shares them.\nLeave the drafts in `draft` status for a human to review in Typefully.\n"},{"path":"agent/tools/create_typefully_tag.ts","type":"registry:file","target":"~/agent/tools/create_typefully_tag.ts","content":"import { defineTool } from \"eve/tools\";\nimport { z } from \"zod\";\n\nimport { hotTopicConfig } from \"../lib/hot-topic-config.js\";\nimport {\n  createTypefullyTag,\n  TypefullyApiError,\n  type TypefullyTagResponse,\n} from \"../lib/typefully-client.js\";\n\nconst TAG_NAME_MAX_CHARS = 60;\n\nconst tagNameSchema = z\n  .string()\n  .min(1)\n  .max(TAG_NAME_MAX_CHARS, `Typefully tag names must be at most ${TAG_NAME_MAX_CHARS} characters.`);\n\nconst inputSchema = z.object({\n  name: tagNameSchema.describe(\n    \"The Typefully tag name to create. Tags are scoped to the configured social set.\",\n  ),\n  confirmCreate: z\n    .boolean()\n    .describe(\n      \"Must be true to create the tag in Typefully. Acts as an explicit guard against accidental creates.\",\n    ),\n});\n\ntype CreatedTag = {\n  readonly created: true;\n  readonly tagId: number;\n  readonly socialSetId: string;\n  readonly name: string;\n  readonly slug?: string | null;\n};\n\ntype FailedTag = {\n  readonly created: false;\n  readonly name: string;\n  readonly error: { readonly message: string; readonly status?: number };\n};\n\ntype CreateTypefullyTagOutput = CreatedTag | FailedTag;\n\nexport default defineTool({\n  description:\n    \"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).\",\n  inputSchema,\n  async execute({ name, confirmCreate }): Promise<CreateTypefullyTagOutput> {\n    const apiKey = process.env.TYPEFULLY_API_KEY;\n    const socialSetId = hotTopicConfig.draft.socialSetId;\n\n    if (!apiKey) {\n      return {\n        name,\n        created: false,\n        error: { message: \"Missing TYPEFULLY_API_KEY environment variable.\" },\n      };\n    }\n\n    if (!socialSetId) {\n      return {\n        name,\n        created: false,\n        error: { message: \"Missing TYPEFULLY_SOCIAL_SET_ID environment variable.\" },\n      };\n    }\n\n    if (!confirmCreate) {\n      return {\n        name,\n        created: false,\n        error: {\n          message: \"confirmCreate must be true to create a tag.\",\n        },\n      };\n    }\n\n    try {\n      const response: TypefullyTagResponse = await createTypefullyTag(\n        { socialSetId, name },\n        apiKey,\n      );\n      return {\n        created: true,\n        tagId: response.id,\n        socialSetId,\n        name: response.name,\n        slug: response.slug ?? null,\n      };\n    } catch (error) {\n      const message =\n        error instanceof TypefullyApiError\n          ? error.message\n          : error instanceof Error\n            ? error.message\n            : String(error);\n      return error instanceof TypefullyApiError\n        ? { name, created: false, error: { message, status: error.status } }\n        : { name, created: false, error: { message } };\n    }\n  },\n});\n"},{"path":"agent/tools/create_x_drafts.ts","type":"registry:file","target":"~/agent/tools/create_x_drafts.ts","content":"import { defineTool } from \"eve/tools\";\nimport { z } from \"zod\";\n\nimport { hotTopicConfig } from \"../lib/hot-topic-config.js\";\nimport {\n  createTypefullyDraft,\n  TypefullyApiError,\n  type TypefullyCreateDraftResponse,\n} from \"../lib/typefully-client.js\";\n\nconst X_POST_MAX_CHARS = 280;\n\nconst postSchema = z\n  .string()\n  .min(1)\n  .max(X_POST_MAX_CHARS, `X posts must be at most ${X_POST_MAX_CHARS} characters.`);\n\nconst draftSchema = z.object({\n  idempotencyKey: z\n    .string()\n    .min(1)\n    .max(255)\n    .describe(\n      \"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.\",\n    ),\n  title: z\n    .string()\n    .min(1)\n    .max(120)\n    .describe(\"Internal Typefully draft title. Not posted to social media.\"),\n  posts: z\n    .array(postSchema)\n    .min(1)\n    .max(25)\n    .describe(\n      \"Ordered X posts that make up the draft. A single post is a tweet; multiple posts are a thread.\",\n    ),\n  scratchpad: z\n    .string()\n    .max(2_000)\n    .optional()\n    .describe(\n      \"Optional private notes attached to the draft in Typefully. Not posted to social media.\",\n    ),\n});\n\nconst draftsSchema = z\n  .array(draftSchema)\n  .min(1)\n  .max(5)\n  .describe(\"Up to 5 X draft candidates to create in Typefully.\");\n\nconst payloadSchema = z.object({\n  drafts: draftsSchema,\n  confirmCreate: z\n    .boolean()\n    .describe(\n      \"Must be true to create drafts in Typefully. Acts as an explicit guard against accidental creates.\",\n    ),\n});\n\ntype CreatedDraft = {\n  readonly idempotencyKey: string;\n  readonly title: string;\n  readonly created: true;\n  readonly draftId: number;\n  readonly socialSetId: string;\n  readonly privateUrl: string;\n  readonly preview: string;\n  readonly status: string;\n};\n\ntype ReplayedDraft = {\n  readonly idempotencyKey: string;\n  readonly title: string;\n  readonly replayed: true;\n  readonly draftId: number;\n  readonly socialSetId: string;\n  readonly privateUrl: string;\n};\n\ntype FailedDraft = {\n  readonly idempotencyKey: string;\n  readonly title: string;\n  readonly created: false;\n  readonly error: { readonly message: string; readonly status?: number };\n};\n\ntype CreateXDraftsOutput = {\n  readonly socialSetId: string;\n  readonly tag?: string;\n  readonly madeWithAi: boolean;\n  readonly createdCount: number;\n  readonly replayedCount: number;\n  readonly failedCount: number;\n  readonly drafts: readonly (CreatedDraft | ReplayedDraft | FailedDraft)[];\n};\n\n// Successful creates are cached so a replayed Eve step returns the recorded\n// result instead of issuing a second POST, as long as the replay happens in\n// the same Node process. The Typefully v2 API does not accept a server-side\n// idempotency key, so the cache is in-process and keyed by the caller-provided\n// idempotencyKey. A replay that crosses a process boundary (serverless cold\n// start, redeploy, restart) sees an empty cache and will POST again — a\n// durable store would be needed to close that gap. Failures are not cached so\n// they can be retried with the same key.\nconst createdCache = new Map<\n  string,\n  { readonly title: string; readonly socialSetId: string; readonly response: TypefullyCreateDraftResponse }\n>();\n\nfunction duplicateIdempotencyKeys(drafts: readonly { idempotencyKey: string }[]): string[] {\n  const seen = new Set<string>();\n  const duplicates = new Set<string>();\n  for (const draft of drafts) {\n    if (seen.has(draft.idempotencyKey)) {\n      duplicates.add(draft.idempotencyKey);\n    } else {\n      seen.add(draft.idempotencyKey);\n    }\n  }\n  return [...duplicates];\n}\n\nexport default defineTool({\n  description:\n    \"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.\",\n  inputSchema: payloadSchema,\n  async execute({ drafts, confirmCreate }): Promise<CreateXDraftsOutput> {\n    const apiKey = process.env.TYPEFULLY_API_KEY;\n    const madeWithAi = hotTopicConfig.draft.madeWithAi;\n    if (!apiKey) {\n      return {\n        socialSetId: hotTopicConfig.draft.socialSetId ?? \"\",\n        madeWithAi,\n        createdCount: 0,\n        replayedCount: 0,\n        failedCount: drafts.length,\n        drafts: drafts.map((draft) => ({\n          idempotencyKey: draft.idempotencyKey,\n          title: draft.title,\n          created: false,\n          error: { message: \"Missing TYPEFULLY_API_KEY environment variable.\" },\n        })),\n      };\n    }\n\n    if (!confirmCreate) {\n      return {\n        socialSetId: hotTopicConfig.draft.socialSetId ?? \"\",\n        madeWithAi,\n        createdCount: 0,\n        replayedCount: 0,\n        failedCount: drafts.length,\n        drafts: drafts.map((draft) => ({\n          idempotencyKey: draft.idempotencyKey,\n          title: draft.title,\n          created: false,\n          error: {\n            message:\n              \"confirmCreate must be true to create drafts. Call preview_x_draft to review them first.\",\n          },\n        })),\n      };\n    }\n\n    const socialSetId = hotTopicConfig.draft.socialSetId;\n    if (!socialSetId) {\n      return {\n        socialSetId: \"\",\n        madeWithAi,\n        createdCount: 0,\n        replayedCount: 0,\n        failedCount: drafts.length,\n        drafts: drafts.map((draft) => ({\n          idempotencyKey: draft.idempotencyKey,\n          title: draft.title,\n          created: false,\n          error: { message: \"Missing TYPEFULLY_SOCIAL_SET_ID environment variable.\" },\n        })),\n      };\n    }\n\n    const duplicates = duplicateIdempotencyKeys(drafts);\n    if (duplicates.length > 0) {\n      return {\n        socialSetId,\n        madeWithAi,\n        createdCount: 0,\n        replayedCount: 0,\n        failedCount: drafts.length,\n        drafts: drafts.map((draft) => ({\n          idempotencyKey: draft.idempotencyKey,\n          title: draft.title,\n          created: false,\n          error: {\n            message: `Duplicate idempotencyKey \"${draft.idempotencyKey}\". Each draft needs a unique key.`,\n          },\n        })),\n      };\n    }\n\n    const tag = hotTopicConfig.draft.tag;\n    const tags = tag ? [tag] : undefined;\n    const results: (CreatedDraft | ReplayedDraft | FailedDraft)[] = [];\n\n    for (const draft of drafts) {\n      const cached = createdCache.get(draft.idempotencyKey);\n      if (cached) {\n        results.push({\n          idempotencyKey: draft.idempotencyKey,\n          title: draft.title,\n          replayed: true,\n          draftId: cached.response.id,\n          socialSetId: cached.socialSetId,\n          privateUrl: cached.response.private_url,\n        });\n        continue;\n      }\n\n      try {\n        const response = await createTypefullyDraft(\n          {\n            socialSetId,\n            posts: draft.posts.map((post) => ({ text: post, madeWithAi })),\n            draftTitle: draft.title,\n            scratchpad: draft.scratchpad,\n            tags,\n          },\n          apiKey,\n        );\n        createdCache.set(draft.idempotencyKey, {\n          title: draft.title,\n          socialSetId,\n          response,\n        });\n        results.push({\n          idempotencyKey: draft.idempotencyKey,\n          title: draft.title,\n          created: true,\n          draftId: response.id,\n          socialSetId,\n          privateUrl: response.private_url,\n          preview: response.preview,\n          status: response.status,\n        });\n      } catch (error) {\n        const message =\n          error instanceof TypefullyApiError\n            ? error.message\n            : error instanceof Error\n              ? error.message\n              : String(error);\n        const failedDraft: FailedDraft =\n          error instanceof TypefullyApiError\n            ? {\n                idempotencyKey: draft.idempotencyKey,\n                title: draft.title,\n                created: false,\n                error: { message, status: error.status },\n              }\n            : {\n                idempotencyKey: draft.idempotencyKey,\n                title: draft.title,\n                created: false,\n                error: { message },\n              };\n        results.push(failedDraft);\n      }\n    }\n\n    const createdCount = results.filter(isCreatedDraft).length;\n    const replayedCount = results.filter(isReplayedDraft).length;\n    const failedCount = results.filter(isFailedDraft).length;\n\n    return {\n      socialSetId,\n      madeWithAi,\n      ...(tag ? { tag } : {}),\n      createdCount,\n      replayedCount,\n      failedCount,\n      drafts: results,\n    };\n  },\n});\n\nfunction isCreatedDraft(draft: CreatedDraft | ReplayedDraft | FailedDraft): draft is CreatedDraft {\n  return \"created\" in draft && draft.created === true;\n}\n\nfunction isReplayedDraft(\n  draft: CreatedDraft | ReplayedDraft | FailedDraft,\n): draft is ReplayedDraft {\n  return \"replayed\" in draft;\n}\n\nfunction isFailedDraft(draft: CreatedDraft | ReplayedDraft | FailedDraft): draft is FailedDraft {\n  return \"created\" in draft && draft.created === false;\n}\n"},{"path":"agent/tools/list_typefully_tags.ts","type":"registry:file","target":"~/agent/tools/list_typefully_tags.ts","content":"import { defineTool } from \"eve/tools\";\nimport { z } from \"zod\";\n\nimport { hotTopicConfig } from \"../lib/hot-topic-config.js\";\nimport {\n  listTypefullyTags,\n  TypefullyApiError,\n  type TypefullyTagResponse,\n} from \"../lib/typefully-client.js\";\n\nexport default defineTool({\n  description:\n    \"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.\",\n  inputSchema: z.object({}),\n  async execute(): Promise<\n    | {\n        readonly socialSetId: string;\n        readonly tags: readonly {\n          readonly id: number;\n          readonly name: string;\n          readonly slug?: string | null;\n        }[];\n      }\n    | { readonly authRequired: true; readonly missingEnv: string }\n    | { readonly notConfigured: true; readonly missingEnv: string }\n    | { readonly failed: true; readonly error: { readonly message: string; readonly status?: number } }\n  > {\n    const apiKey = process.env.TYPEFULLY_API_KEY;\n    if (!apiKey) {\n      return { authRequired: true, missingEnv: \"TYPEFULLY_API_KEY\" };\n    }\n\n    const socialSetId = hotTopicConfig.draft.socialSetId;\n    if (!socialSetId) {\n      return { notConfigured: true, missingEnv: \"TYPEFULLY_SOCIAL_SET_ID\" };\n    }\n\n    try {\n      const tags: readonly TypefullyTagResponse[] = await listTypefullyTags(socialSetId, apiKey);\n      return {\n        socialSetId,\n        tags: tags.map((tag) => ({\n          id: tag.id,\n          name: tag.name,\n          slug: tag.slug ?? null,\n        })),\n      };\n    } catch (error) {\n      const message =\n        error instanceof TypefullyApiError\n          ? error.message\n          : error instanceof Error\n            ? error.message\n            : String(error);\n      return error instanceof TypefullyApiError\n        ? { failed: true, error: { message, status: error.status } }\n        : { failed: true, error: { message } };\n    }\n  },\n});\n"},{"path":"agent/tools/preview_x_draft.ts","type":"registry:file","target":"~/agent/tools/preview_x_draft.ts","content":"import { defineTool } from \"eve/tools\";\nimport { z } from \"zod\";\n\nimport { hotTopicConfig } from \"../lib/hot-topic-config.js\";\n\nconst X_POST_MAX_CHARS = 280;\n\nconst postSchema = z\n  .string()\n  .min(1)\n  .max(X_POST_MAX_CHARS, `X posts must be at most ${X_POST_MAX_CHARS} characters.`);\n\nconst draftSchema = z.object({\n  title: z\n    .string()\n    .min(1)\n    .max(120)\n    .describe(\"Internal Typefully draft title. Not posted to social media.\"),\n  posts: z\n    .array(postSchema)\n    .min(1)\n    .max(25)\n    .describe(\n      \"Ordered X posts that make up the draft. A single post is a tweet; multiple posts are a thread.\",\n    ),\n  scratchpad: z\n    .string()\n    .max(2_000)\n    .optional()\n    .describe(\n      \"Optional private notes attached to the draft in Typefully. Not posted to social media.\",\n    ),\n});\n\nconst draftsSchema = z\n  .array(draftSchema)\n  .min(1)\n  .max(5)\n  .describe(\"Up to 5 X draft candidates to preview before creating them in Typefully.\");\n\nexport default defineTool({\n  description:\n    \"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.\",\n  inputSchema: z.object({\n    drafts: draftsSchema,\n  }),\n  async execute({ drafts }) {\n    const apiKey = process.env.TYPEFULLY_API_KEY;\n    if (!apiKey) {\n      return { authRequired: true, missingEnv: \"TYPEFULLY_API_KEY\" };\n    }\n\n    const socialSetId = hotTopicConfig.draft.socialSetId;\n    if (!socialSetId) {\n      return { notConfigured: true, missingEnv: \"TYPEFULLY_SOCIAL_SET_ID\" };\n    }\n\n    return {\n      dryRun: true,\n      socialSetId,\n      tag: hotTopicConfig.draft.tag ?? null,\n      madeWithAi: hotTopicConfig.draft.madeWithAi,\n      draftCount: drafts.length,\n      drafts: drafts.map((draft) => ({\n        title: draft.title,\n        postCount: draft.posts.length,\n        posts: draft.posts,\n        postChars: draft.posts.map((post) => post.length),\n        maxChars: X_POST_MAX_CHARS,\n        madeWithAi: hotTopicConfig.draft.madeWithAi,\n        scratchpad: draft.scratchpad ?? null,\n      })),\n    };\n  },\n});\n"},{"path":"agent/tools/research_hot_topics.ts","type":"registry:file","target":"~/agent/tools/research_hot_topics.ts","content":"import Parallel from \"parallel-web\";\nimport { defineTool } from \"eve/tools\";\nimport { z } from \"zod\";\n\nimport { hotTopicConfig } from \"../lib/hot-topic-config.js\";\n\nexport default defineTool({\n  description:\n    \"Research a hot topic with the Parallel web search API and return ranked excerpts with provenance.\",\n  inputSchema: z.object({\n    topic: z.string().min(1).describe(\"The hot topic to research, in natural language.\"),\n    searchQueries: z\n      .array(z.string().min(1))\n      .min(1)\n      .max(5)\n      .describe(\"2-3 concise keyword queries (3-6 words each) to focus the search.\"),\n    maxResults: z\n      .number()\n      .int()\n      .min(1)\n      .max(10)\n      .optional()\n      .describe(\"Upper bound on returned results. Defaults to the agent config.\"),\n  }),\n  async execute({ topic, searchQueries, maxResults }) {\n    const apiKey = process.env.PARALLEL_API_KEY;\n    if (!apiKey) {\n      return { authRequired: true, missingEnv: \"PARALLEL_API_KEY\", topic };\n    }\n\n    const client = new Parallel({ apiKey });\n    const { results } = await client.search({\n      objective: `Research the following hot topic surfaced from X: ${topic}`,\n      search_queries: searchQueries,\n      mode: hotTopicConfig.searchMode,\n      advanced_settings: {\n        max_results: maxResults ?? hotTopicConfig.searchMaxResults,\n      },\n    });\n\n    return {\n      topic,\n      resultCount: results.length,\n      results: results.map((entry) => ({\n        url: entry.url,\n        title: entry.title ?? null,\n        publishDate: entry.publish_date ?? null,\n        excerpts: entry.excerpts,\n      })),\n    };\n  },\n});\n"},{"path":"agent/tools/scan_x_profiles.ts","type":"registry:file","target":"~/agent/tools/scan_x_profiles.ts","content":"import { defineTool } from \"eve/tools\";\nimport { z } from \"zod\";\n\nimport { getLookbackStartTime, hotTopicConfig } from \"../lib/hot-topic-config.js\";\n\nconst X_API_BASE = \"https://api.x.com/2\";\nconst TWEET_FIELDS = \"created_at,public_metrics,entities,lang\";\nconst EXCLUDE = \"retweets\";\nconst MIN_MAX_RESULTS = 5;\nconst MAX_MAX_RESULTS = 100;\n\ntype XPublicMetrics = {\n  readonly impression_count?: number;\n  readonly like_count?: number;\n  readonly reply_count?: number;\n  readonly retweet_count?: number;\n  readonly quote_count?: number;\n  readonly bookmark_count?: number;\n};\n\ntype XTweet = {\n  readonly id: string;\n  readonly text: string;\n  readonly created_at?: string;\n  readonly lang?: string;\n  readonly public_metrics?: XPublicMetrics;\n};\n\ntype XUserLookupResponse = {\n  readonly data?: { readonly id: string; readonly name: string; readonly username: string };\n};\n\ntype XTweetsResponse = {\n  readonly data?: readonly XTweet[];\n  readonly meta?: { readonly result_count?: number; readonly newest_id?: string };\n};\n\nconst userIdCache = new Map<string, string>();\n\nasync function xFetch<T>(path: string, searchParams?: URLSearchParams): Promise<T> {\n  const bearer = process.env.X_BEARER_TOKEN;\n  if (!bearer) {\n    throw new Error(\"Missing X_BEARER_TOKEN environment variable.\");\n  }\n\n  const url = searchParams ? `${path}?${searchParams.toString()}` : path;\n  const response = await fetch(`${X_API_BASE}${url}`, {\n    headers: { Authorization: `Bearer ${bearer}` },\n  });\n\n  if (!response.ok) {\n    const body = await response.text();\n    throw new Error(`X API ${response.status} for ${path}: ${body.slice(0, 500)}`);\n  }\n\n  return (await response.json()) as T;\n}\n\nasync function resolveUserId(handle: string): Promise<string> {\n  const normalized = handle.replace(/^@/, \"\");\n  const cached = userIdCache.get(normalized);\n  if (cached) return cached;\n\n  const lookup = await xFetch<XUserLookupResponse>(\n    `/users/by/username/${encodeURIComponent(normalized)}`,\n  );\n  if (!lookup.data?.id) {\n    throw new Error(`Could not resolve X user id for @${normalized}.`);\n  }\n\n  userIdCache.set(normalized, lookup.data.id);\n  return lookup.data.id;\n}\n\nasync function fetchUserTweets(handle: string, startTime: string): Promise<readonly XTweet[]> {\n  const userId = await resolveUserId(handle);\n  const maxResults = Math.min(\n    Math.max(hotTopicConfig.maxTweetsPerProfile, MIN_MAX_RESULTS),\n    MAX_MAX_RESULTS,\n  );\n  const params = new URLSearchParams({\n    max_results: maxResults.toString(),\n    \"tweet.fields\": TWEET_FIELDS,\n    exclude: EXCLUDE,\n    start_time: startTime,\n  });\n\n  const payload = await xFetch<XTweetsResponse>(`/users/${userId}/tweets`, params);\n  return payload.data ?? [];\n}\n\nfunction withinLookback(tweet: XTweet, startTimeMs: number): boolean {\n  if (!tweet.created_at) return false;\n  const createdAt = Date.parse(tweet.created_at);\n  return Number.isFinite(createdAt) && createdAt >= startTimeMs;\n}\n\nfunction summarizeTweet(tweet: XTweet) {\n  return {\n    id: tweet.id,\n    text: tweet.text,\n    createdAt: tweet.created_at,\n    lang: tweet.lang,\n    likes: tweet.public_metrics?.like_count ?? 0,\n    replies: tweet.public_metrics?.reply_count ?? 0,\n    reposts: tweet.public_metrics?.retweet_count ?? 0,\n    quotes: tweet.public_metrics?.quote_count ?? 0,\n    impressions: tweet.public_metrics?.impression_count ?? 0,\n  };\n}\n\nexport default defineTool({\n  description:\n    \"Scan configured X (Twitter) profiles for recent posts to surface hot topics. Uses X API v2 app-only bearer auth.\",\n  inputSchema: z.object({\n    handles: z\n      .array(z.string().min(1))\n      .optional()\n      .describe(\n        \"X handles to scan. Defaults to the X_HOT_TOPIC_HANDLES environment variable.\",\n      ),\n  }),\n  async execute({ handles }) {\n    const bearer = process.env.X_BEARER_TOKEN;\n    if (!bearer) {\n      return { authRequired: true, missingEnv: \"X_BEARER_TOKEN\" };\n    }\n\n    const targetHandles = handles?.length ? handles : hotTopicConfig.handles;\n    if (targetHandles.length === 0) {\n      return {\n        scannedProfiles: 0,\n        profiles: [],\n        note: \"No handles configured. Set X_HOT_TOPIC_HANDLES or pass handles explicitly.\",\n      };\n    }\n\n    const startTime = getLookbackStartTime();\n    const startTimeMs = Date.parse(startTime);\n\n    const profiles = [];\n    for (const handle of targetHandles) {\n      try {\n        const tweets = (await fetchUserTweets(handle, startTime)).filter((tweet) =>\n          withinLookback(tweet, startTimeMs),\n        );\n        profiles.push({\n          handle,\n          ok: true,\n          tweetCount: tweets.length,\n          tweets: tweets.map(summarizeTweet),\n        });\n      } catch (error) {\n        profiles.push({\n          handle,\n          ok: false,\n          error: error instanceof Error ? error.message : String(error),\n        });\n      }\n    }\n\n    const totalTweets = profiles.reduce(\n      (sum, profile) => sum + (profile.ok ? (profile.tweetCount ?? 0) : 0),\n      0,\n    );\n\n    return {\n      scannedProfiles: profiles.length,\n      totalTweets,\n      lookbackHours: hotTopicConfig.lookbackHours,\n      windowStart: startTime,\n      profiles,\n    };\n  },\n});\n"},{"path":"evals/create-confirmation.eval.ts","type":"registry:file","target":"~/evals/create-confirmation.eval.ts","content":"import { defineEval } from \"eve/evals\";\nimport { equals, includes } from \"eve/evals/expect\";\n\nexport default defineEval({\n  description:\n    \"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.\",\n  async test(t) {\n    const turn = await t.send(`\nThe 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.\n\nNow 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.\n`);\n\n    const call = turn.requireToolCall(\"create_x_drafts\");\n    t.check(call.input.confirmCreate, equals(true).gate());\n    const drafts = call.input.drafts as readonly { idempotencyKey?: string }[];\n    t.check(drafts.length === 3, equals(true).gate());\n    const keys = new Set<string>();\n    let allKeysUnique = true;\n    for (const draft of drafts) {\n      const key = draft.idempotencyKey;\n      if (typeof key !== \"string\" || key.length === 0 || keys.has(key)) {\n        allKeysUnique = false;\n      }\n      keys.add(key ?? \"\");\n    }\n    t.check(allKeysUnique, equals(true).gate());\n    t.check(call.input.socialSetId === undefined, equals(true).soft());\n    t.check(call.input.tag === undefined, equals(true).soft());\n    t.check(call.input.madeWithAi === undefined, equals(true).soft());\n    t.check(t.reply, includes(\"x-draft-assistant-2026-06-26T08:00:00Z-1\").soft());\n    const replyLower = (t.reply ?? \"\").toLowerCase();\n    t.check(replyLower, includes(\"made with ai\").soft());\n  },\n});\n"},{"path":"evals/evals.config.ts","type":"registry:file","target":"~/evals/evals.config.ts","content":"import { defineEvalConfig } from \"eve/evals\";\n\nexport default defineEvalConfig({\n  timeoutMs: 120_000,\n});\n"},{"path":"evals/failed-create-no-retry.eval.ts","type":"registry:file","target":"~/evals/failed-create-no-retry.eval.ts","content":"import { defineEval } from \"eve/evals\";\nimport { equals, includes } from \"eve/evals/expect\";\n\nexport default defineEval({\n  description:\n    \"Reports a partially failed create_x_drafts result without retrying in the same step and without claiming every draft was created.\",\n  async test(t) {\n    await t.send(`\nThe three previewed and approved drafts were submitted with create_x_drafts and the tool returned:\n\n{\n  \"createdCount\": 2,\n  \"replayedCount\": 0,\n  \"failedCount\": 1,\n  \"drafts\": [\n    { \"idempotencyKey\": \"x-draft-assistant-2026-06-25T08:00:00Z-1\", \"created\": true, \"draftId\": \"d_101\", \"privateUrl\": \"https://typefully.com/drafts/d_101\" },\n    { \"idempotencyKey\": \"x-draft-assistant-2026-06-25T08:00:00Z-2\", \"created\": true, \"draftId\": \"d_102\", \"privateUrl\": \"https://typefully.com/drafts/d_102\" },\n    { \"idempotencyKey\": \"x-draft-assistant-2026-06-25T08:00:00Z-3\", \"created\": false, \"error\": { \"message\": \"Typefully API 429: rate limited\", \"status\": 429 } }\n  ]\n}\n\nProceed according to your instructions: report the created drafts and the failure clearly, and do not retry create_x_drafts in this same step.\n`);\n\n    t.succeeded();\n    t.noFailedActions();\n    t.notCalledTool(\"create_x_drafts\").gate();\n    const replyLower = (t.reply ?? \"\").toLowerCase();\n    t.check(\n      replyLower.includes(\"429\") || replyLower.includes(\"rate limit\"),\n      equals(true).gate(),\n    );\n    t.check(t.reply, includes(\"x-draft-assistant-2026-06-25T08:00:00Z-3\").soft());\n    t.check(t.reply, includes(\"d_101\").soft());\n  },\n});\n"},{"path":"evals/missing-config-does-not-create.eval.ts","type":"registry:file","target":"~/evals/missing-config-does-not-create.eval.ts","content":"import { defineEval } from \"eve/evals\";\nimport { includes } from \"eve/evals/expect\";\n\nexport default defineEval({\n  description:\n    \"When required configuration is missing, the agent stops and reports it instead of creating any Typefully drafts.\",\n  async test(t) {\n    await t.send(`\nRun the daily X hot topic Typefully drafts.\n\nThe scan_x_profiles tool returned:\n\n{\n  \"authRequired\": true,\n  \"missingEnv\": \"X_BEARER_TOKEN\"\n}\n\nNo 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.\n`);\n\n    t.succeeded();\n    t.noFailedActions();\n    t.notCalledTool(\"create_x_drafts\").gate();\n    t.notCalledTool(\"preview_x_draft\").gate();\n    t.check(t.reply, includes(\"X_BEARER_TOKEN\").gate());\n  },\n});\n"},{"path":"evals/x-draft-assistant.eval.ts","type":"registry:file","target":"~/evals/x-draft-assistant.eval.ts","content":"import { defineEval } from \"eve/evals\";\nimport { includes } from \"eve/evals/expect\";\n\nexport default defineEval({\n  description:\n    \"Scans a sample of X posts, researches hot topics with Parallel, and previews three X draft candidates without creating them in Typefully.\",\n  async test(t) {\n    await t.send(`\nRun the daily X hot topic Typefully drafts for the following sample posts.\n\nWatched handles: vercel, parallel_ai\n\nSample scan_x_profiles output:\n{\n  \"scannedProfiles\": 2,\n  \"totalTweets\": 2,\n  \"lookbackHours\": 24,\n  \"windowStart\": \"2026-06-25T08:00:00Z\",\n  \"profiles\": [\n    {\n      \"handle\": \"vercel\",\n      \"ok\": true,\n      \"tweetCount\": 1,\n      \"tweets\": [\n        {\n          \"id\": \"1700000000000000001\",\n          \"text\": \"We just shipped AI SDK 5 with native agent loops and durable execution.\",\n          \"createdAt\": \"2026-06-26T07:00:00.000Z\",\n          \"likes\": 320,\n          \"replies\": 22,\n          \"reposts\": 45,\n          \"quotes\": 8,\n          \"impressions\": 12000\n        }\n      ]\n    },\n    {\n      \"handle\": \"parallel_ai\",\n      \"ok\": true,\n      \"tweetCount\": 1,\n      \"tweets\": [\n        {\n          \"id\": \"1700000000000000002\",\n          \"text\": \"Parallel Monitor API is now GA: web change events streamed to proactive agents.\",\n          \"createdAt\": \"2026-06-26T07:30:00.000Z\",\n          \"likes\": 210,\n          \"replies\": 14,\n          \"reposts\": 33,\n          \"quotes\": 5,\n          \"impressions\": 9000\n        }\n      ]\n    }\n  ]\n}\n\nSurface 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.\n`);\n\n    t.succeeded();\n    t.noFailedActions();\n    t.calledTool(\"research_hot_topics\").gate();\n    t.calledTool(\"preview_x_draft\").gate();\n    t.notCalledTool(\"create_x_drafts\").gate();\n    t.check(t.reply, includes(\"dryRun\").soft());\n    const replyLower = (t.reply ?? \"\").toLowerCase();\n    t.check(replyLower, includes(\"made with ai\").soft());\n  },\n});\n"},{"path":"README.md","type":"registry:file","target":"~/agent/README.md","content":"# X Draft Assistant\n\nA 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.\n\nIt 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.\n\n## What it does\n\n1. **Scan X profiles** — pulls recent posts (excluding retweets) from each handle in `X_HOT_TOPIC_HANDLES` using X API v2 app-only bearer auth.\n2. **Surface hot topics** — clusters the posts into up to `X_HOT_TOPIC_MAX_TOPICS` themes based on recurrence and engagement.\n3. **Research with Parallel** — for each topic, calls the Parallel Search API with focused keyword queries and returns ranked web sources with provenance.\n4. **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.\n5. **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`).\n\n## Skills\n\n- **typefully-best-practices** — X automation compliance, character limits, and the exactly-once draft creation model. Loaded before creating any X draft.\n- **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.\n\n## Installation\n\n```bash\nnpx shadcn@latest add @evex/x-draft-assistant\n```\n\n## Configuration\n\nCopy `.env.example` into your Eve app environment and fill in the values.\n\n### X credentials\n\n- `X_BEARER_TOKEN` — app-only bearer token from the X Developer Console. Required to read public posts.\n\n### Watched profiles and schedule\n\n- `X_HOT_TOPIC_HANDLES` — comma-separated X handles to scan (with or without `@`). Example: `vercel,parallel_ai,anthropicai`.\n- `X_HOT_TOPIC_DAILY_CRON` — 5-field cron expression (UTC on Vercel). Defaults to `0 8 * * *` (daily at 08:00 UTC).\n- `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.\n- `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.\n- `X_HOT_TOPIC_MAX_TOPICS` — max hot topics surfaced per run. Defaults to `5`.\n- `X_HOT_TOPIC_SEARCH_MAX_RESULTS` — max Parallel search results per topic. Defaults to `5`.\n- `X_HOT_TOPIC_SEARCH_MODE` — Parallel search mode: `turbo`, `basic`, or `advanced`. Defaults to `basic`.\n\n### Draft candidates\n\n- `X_HOT_TOPIC_DRAFT_COUNT` — number of distinct X draft candidates to produce per run. Defaults to `3`.\n- `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.\n- `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.\n\n### Typefully credentials\n\n- `TYPEFULLY_API_KEY` — Typefully API key from [typefully.com/?settings=api](https://typefully.com/?settings=api).\n- `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.\n\nCreating 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.\n\nWhen `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.\n\n### Parallel credentials\n\n- `PARALLEL_API_KEY` — Parallel API key from [platform.parallel.ai](https://platform.parallel.ai).\n\n## Smoke test\n\n1. Set `X_BEARER_TOKEN`, `PARALLEL_API_KEY`, `TYPEFULLY_API_KEY`, `TYPEFULLY_SOCIAL_SET_ID`, and at least one handle in `X_HOT_TOPIC_HANDLES`.\n2. Trigger the schedule while iterating in dev:\n\n   ```bash\n   curl -X POST http://localhost:3000/eve/v1/dev/schedules/daily-x-drafts\n   ```\n\n3. 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.\n4. After the run, open Typefully for the configured social set: the three drafts should appear in `draft` status, not scheduled and not published.\n\n## Troubleshooting\n\n- **`authRequired: missingEnv X_BEARER_TOKEN`** — the X bearer token is missing or empty.\n- **`Could not resolve X user id`** — a handle is wrong, suspended, or the app does not have access to user lookup.\n- **`authRequired: missingEnv PARALLEL_API_KEY`** — the Parallel API key is missing.\n- **`authRequired: missingEnv TYPEFULLY_API_KEY`** — the Typefully API key is missing.\n- **`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.\n- **`notConfirmed: true`** — `create_x_drafts` was called without `confirmCreate: true`. Review the preview first, then call it with the flag set.\n- **`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`).\n- **`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.\n- **`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.\n- **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.\n\n## X automation compliance\n\nThe 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.\n\n## Development\n\n```bash\npnpm install\npnpm dev\n```\n\nRun `pnpm info` to inspect the Eve surface and `pnpm build` before opening a PR.\n"},{"path":".env.example","type":"registry:file","target":"~/.env.example","content":"X_BEARER_TOKEN=\n\nX_HOT_TOPIC_HANDLES=\n\nX_HOT_TOPIC_DAILY_CRON=\"0 8 * * *\"\nX_HOT_TOPIC_LOOKBACK_HOURS=24\nX_HOT_TOPIC_MAX_TWEETS_PER_PROFILE=20\nX_HOT_TOPIC_MAX_TOPICS=5\nX_HOT_TOPIC_SEARCH_MAX_RESULTS=5\nX_HOT_TOPIC_SEARCH_MODE=basic\n\nX_HOT_TOPIC_DRAFT_COUNT=3\nX_HOT_TOPIC_DRAFT_MADE_WITH_AI=true\nX_HOT_TOPIC_DRAFT_TAG=\n\nPARALLEL_API_KEY=\nTYPEFULLY_API_KEY=\nTYPEFULLY_SOCIAL_SET_ID=\n"}]}