├── next.config.ts
├── eslint.config.mjs
├── .env.local.example
├── app
├── constants.ts
└── api
│ └── tweet
│ └── route.ts
├── .gitignore
├── tsconfig.json
├── package.json
├── LICENSE
└── README.md
/next.config.ts:
--------------------------------------------------------------------------------
1 | import type { NextConfig } from "next";
2 |
3 | const nextConfig: NextConfig = {
4 | /* config options here */
5 | };
6 |
7 | export default nextConfig;
8 |
--------------------------------------------------------------------------------
/eslint.config.mjs:
--------------------------------------------------------------------------------
1 | import { dirname } from "path";
2 | import { fileURLToPath } from "url";
3 | import { FlatCompat } from "@eslint/eslintrc";
4 |
5 | const __filename = fileURLToPath(import.meta.url);
6 | const __dirname = dirname(__filename);
7 |
8 | const compat = new FlatCompat({
9 | baseDirectory: __dirname,
10 | });
11 |
12 | const eslintConfig = [
13 | ...compat.extends("next/core-web-vitals", "next/typescript"),
14 | ];
15 |
16 | export default eslintConfig;
17 |
--------------------------------------------------------------------------------
/.env.local.example:
--------------------------------------------------------------------------------
1 | # To power the workflow
2 | QSTASH_TOKEN=
3 |
4 | # To make sure requests are coming from the right source
5 | QSTASH_CURRENT_SIGNING_KEY=
6 | QSTASH_NEXT_SIGNING_KEY=
7 |
8 | # To keep track of the news articles visited
9 | UPSTASH_REDIS_REST_URL=
10 | UPSTASH_REDIS_REST_TOKEN=
11 |
12 | # To power the agent
13 | OPENAI_API_KEY=
14 |
15 | # To generate images
16 | IDEOGRAM_API_KEY=
17 |
18 | # To be able to tweet
19 | TWITTER_CONSUMER_KEY=
20 | TWITTER_CONSUMER_SECRET=
21 | TWITTER_ACCESS_TOKEN=
22 | TWITTER_ACCESS_TOKEN_SECRET=
--------------------------------------------------------------------------------
/app/constants.ts:
--------------------------------------------------------------------------------
1 | export const TOP_SLICE = 100;
2 |
3 | export const MAX_CONTENT_LENGTH = 50000;
4 |
5 | export const SELECTORS_TO_REMOVE = [
6 | "script",
7 | "style",
8 | "header",
9 | "footer",
10 | "nav",
11 | "iframe",
12 | "noscript",
13 | "svg",
14 | '[role="banner"]',
15 | '[role="navigation"]',
16 | '[role="complementary"]',
17 | ".ad",
18 | ".advertisement",
19 | ".social-share",
20 | "aside",
21 | ".sidebar",
22 | "#sidebar",
23 | ".comments",
24 | "#comments",
25 | ];
26 |
27 | export const TWEET_DEDUPLICATOR_TIMEOUT = 100;
28 |
29 | export const MIN_SLEEP_TIME = 3;
30 | export const MAX_SLEEP_TIME = 9000;
31 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
2 |
3 | # dependencies
4 | /node_modules
5 | /.pnp
6 | .pnp.*
7 | .yarn/*
8 | !.yarn/patches
9 | !.yarn/plugins
10 | !.yarn/releases
11 | !.yarn/versions
12 |
13 | # testing
14 | /coverage
15 |
16 | # next.js
17 | /.next/
18 | /out/
19 |
20 | # production
21 | /build
22 |
23 | # misc
24 | .DS_Store
25 | *.pem
26 |
27 | # debug
28 | npm-debug.log*
29 | yarn-debug.log*
30 | yarn-error.log*
31 | .pnpm-debug.log*
32 |
33 | # env files (can opt-in for committing if needed)
34 | .env
35 | .env*.local
36 |
37 | # vercel
38 | .vercel
39 |
40 | # typescript
41 | *.tsbuildinfo
42 | next-env.d.ts
43 |
44 | bootstrap.sh
45 | ngrok.log
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "target": "ES2017",
4 | "lib": ["dom", "dom.iterable", "esnext"],
5 | "allowJs": true,
6 | "skipLibCheck": true,
7 | "strict": true,
8 | "noEmit": true,
9 | "esModuleInterop": true,
10 | "module": "esnext",
11 | "moduleResolution": "bundler",
12 | "resolveJsonModule": true,
13 | "isolatedModules": true,
14 | "jsx": "preserve",
15 | "incremental": true,
16 | "plugins": [
17 | {
18 | "name": "next"
19 | }
20 | ],
21 | "paths": {
22 | "@/*": ["./*"]
23 | }
24 | },
25 | "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
26 | "exclude": ["node_modules"]
27 | }
28 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "hacker-news-x-agent",
3 | "version": "0.1.0",
4 | "private": true,
5 | "scripts": {
6 | "dev": "next dev",
7 | "build": "next build",
8 | "start": "next start",
9 | "lint": "next lint"
10 | },
11 | "dependencies": {
12 | "@agentic/hacker-news": "^7.2.0",
13 | "@upstash/redis": "^1.34.5",
14 | "@upstash/workflow": "^0.2.11",
15 | "cheerio": "^1.0.0",
16 | "next": "15.1.11",
17 | "twitter-api-v2": "^1.19.1"
18 | },
19 | "devDependencies": {
20 | "@eslint/eslintrc": "^3",
21 | "@types/node": "^20",
22 | "@types/react": "19.0.8",
23 | "eslint": "^9",
24 | "eslint-config-next": "15.1.9",
25 | "typescript": "^5"
26 | }
27 | }
28 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | The MIT License (MIT)
2 |
3 | Copyright (c) 2024 Upstash, Inc.
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Hacker News X Agent with Workflow
2 |
3 | This is a simple agent that fetches the top stories from Hacker News and tweets a summary. Agent orchestration is done using `@upstash/workflow` agents.
4 |
5 | To see the agent in action, follow [@hackernewsagent](https://x.com/hackernewsagent) on X.
6 |
7 | To learn more about how the agent works, check out our [blog post](https://upstash.com/blog/hacker-news-x-agent).
8 |
9 | ## Setup Instructions
10 |
11 | ### Fill Environment Variables
12 |
13 | 1. Clone this repository.
14 |
15 | ```bash
16 | git clone https://github.com/upstash/hacker-news-x-agent.git
17 | ```
18 |
19 | 2. Install dependencies.
20 |
21 | ```bash
22 | npm install
23 | ```
24 |
25 | 3. Create a `.env.local` file in the root directory and copy the contents from `.env.local.example`. Fill these environment variables following the instructions in the next steps.
26 |
27 | 4. Go to [QStash tab on Upstash Console](https://console.upstash.com/qstash). Fill the following environment variables in `.env.local` with the values found in the **Environment Keys** section:
28 |
29 | ```bash
30 | # To power the workflow
31 | QSTASH_TOKEN=
32 |
33 | # To make sure requests are coming from the right source
34 | QSTASH_CURRENT_SIGNING_KEY=
35 | QSTASH_NEXT_SIGNING_KEY=
36 | ```
37 |
38 | 5. Go to [Redis tab on Upstash Console](https://console.upstash.com/redis). Create a new Redis database and fill the following environment variables in `.env.local` with the values found in the **REST API** section `.env` tab:
39 |
40 | ```bash
41 | # To keep track of the news articles visited
42 | UPSTASH_REDIS_REST_URL=
43 | UPSTASH_REDIS_REST_TOKEN=
44 | ```
45 |
46 | 6. Go to [OpenAI Platform -> API Keys](https://platform.openai.com/api-keys) and create a new API key. Fill the following environment variables in `.env.local`:
47 |
48 | ```bash
49 | # To power the agent
50 | OPENAI_API_KEY=
51 | ```
52 |
53 | 7. Go to [ideogram](https://ideogram.ai/) and create a new API key. Fill the following environment variables in `.env.local`:
54 |
55 | ```bash
56 | # To generate images
57 | IDEOGRAM_API_KEY=
58 | ```
59 |
60 |
61 | 8. Set up X API
62 |
63 | 1. Go to [X Website](https://x.com/) and create an account.
64 |
65 | 
66 |
67 | 2. Go to [X Developer Portal](https://developer.x.com/en/portal/dashboard) and sign up for a a free developer account.
68 |
69 | 
70 |
71 | 3. Fill the developer agreement & policy according to your needs.
72 |
73 | 
74 |
75 | 4. Go to project settings.
76 |
77 | 
78 |
79 | 5. Set up User authentication settings.
80 |
81 | 
82 |
83 | 6. Fill the form and save.
84 |
85 | 
86 |
87 | 
88 |
89 | 7. Make sure User authentication is set up.
90 |
91 | 
92 |
93 | 8. Fill the following environment variables in `.env.local` with the values found under the **Keys and tokens** tab:
94 |
95 | ```bash
96 | # To be able to tweet
97 | TWITTER_CONSUMER_KEY=
98 | TWITTER_CONSUMER_SECRET=
99 | TWITTER_ACCESS_TOKEN=
100 | TWITTER_ACCESS_TOKEN_SECRET=
101 | ```
102 |
103 | 
104 |
105 |
106 |
107 | ### Deploy the Agent
108 |
109 | 1. Deploy the agent to Vercel.
110 |
111 | ```bash
112 | vercel
113 | ```
114 |
115 | 2. Go to the Vercel Dashboard -> Your Project -> Environment Variables and paste the contents of `.env.local` there, you don't need to set them one by one.
116 |
117 | 3. Deploy the agent to production.
118 |
119 | ```bash
120 | vercel --prod
121 | ```
122 |
123 | ### Calling the Agent
124 |
125 | 1. To secure the calls to the agent, only requests signed by QStash are allowed.
126 | If you don't want this security layer, you can just leave the following environment variables empty. You can learn more about how to [Secure an endpoint with our guide](https://upstash.com/docs/workflow/howto/security).
127 |
128 | ```bash
129 | # To make sure requests are coming from the right source
130 | QSTASH_CURRENT_SIGNING_KEY=
131 | QSTASH_NEXT_SIGNING_KEY=
132 | ```
133 |
134 | 2. Go to [QStash tab on Upstash Console](https://console.upstash.com/qstash) and publish a message.
135 |
136 | 
137 |
138 | ### Schedule the Agent
139 |
140 | 1. Go to [QStash tab on Upstash Console](https://console.upstash.com/qstash) and create a new schedule with Request Builder. Keep the limits of X API and QStash in mind while setting the schedule frequency. Cron expression `0 */2 * * *` will run the agent every 2 hours.
141 |
142 | 
143 |
144 | ### Local Development
145 |
146 | 1. Check out our [Local Development Guide](https://upstash.com/docs/workflow/howto/local-development) to learn how to work with `@upstash/workflow` agents locally.
147 |
148 | 2. You can run the agent locally with the following command:
149 |
150 | ```bash
151 | npm run dev
152 | ```
153 |
--------------------------------------------------------------------------------
/app/api/tweet/route.ts:
--------------------------------------------------------------------------------
1 | import { serve } from "@upstash/workflow/nextjs";
2 | import { WorkflowTool } from "@upstash/workflow";
3 | import { Redis } from "@upstash/redis";
4 |
5 | import { HackerNewsClient } from "@agentic/hacker-news";
6 | import { TwitterApi } from "twitter-api-v2";
7 |
8 | import * as cheerio from "cheerio";
9 | import { z } from "zod";
10 | import { tool } from "ai";
11 |
12 | import {
13 | TOP_SLICE,
14 | SELECTORS_TO_REMOVE,
15 | MAX_CONTENT_LENGTH,
16 | TWEET_DEDUPLICATOR_TIMEOUT,
17 | MIN_SLEEP_TIME,
18 | MAX_SLEEP_TIME,
19 | } from "@/app/constants";
20 |
21 | type IdeogramResponse = {
22 | created: string;
23 | data: Array<{
24 | prompt: string;
25 | url: string;
26 | }>;
27 | };
28 |
29 | export const { POST } = serve<{ prompt: string }>(
30 | async (context) => {
31 | const model = context.agents.openai("gpt-4o-mini");
32 |
33 | const hackerNewsTwitterAgent = context.agents.agent({
34 | model,
35 | name: "hackerNewsTwitterAgent",
36 | maxSteps: 2,
37 | tools: {
38 | hackerNewsTool: tool({
39 | description:
40 | "A tool for fetching the top 1 unvisited Hacker News article. It returns an " +
41 | "object with the title, url, and content of the article. It does not take any " +
42 | "parameters, so give an empty object as a parameter. You absolutely should not " +
43 | "give an empty string directly as a parameter.",
44 | parameters: z.object({}),
45 | execute: async ({}) => {
46 | const redis = Redis.fromEnv();
47 | const hn = new HackerNewsClient();
48 | const top100 = (await hn.getTopStories()).slice(0, TOP_SLICE);
49 | const top1Unvisited =
50 | top100[
51 | (await redis.smismember("visited", top100)).findIndex(
52 | (v) => v === 0
53 | )
54 | ];
55 | await redis.sadd("visited", top1Unvisited);
56 | const item = await hn.getItem(top1Unvisited);
57 | const title = item.title;
58 | const url = item.url;
59 |
60 | if (!url) {
61 | return {
62 | title,
63 | url,
64 | content: "",
65 | };
66 | }
67 |
68 | const html = await fetch(url).then((res) => res.text());
69 |
70 | const $ = cheerio.load(html);
71 |
72 | SELECTORS_TO_REMOVE.forEach((selector) => {
73 | $(selector).remove();
74 | });
75 |
76 | let $content = $('main, article, [role="main"]');
77 |
78 | if (!$content.length) {
79 | $content = $("body");
80 | }
81 |
82 | const content = $content
83 | .text()
84 | .replace(/\s+/g, " ")
85 | .replace(/\n\s*/g, "\n")
86 | .trim()
87 | .slice(0, MAX_CONTENT_LENGTH);
88 |
89 | return {
90 | title,
91 | url,
92 | content,
93 | };
94 | },
95 | }),
96 | twitterTool: new WorkflowTool({
97 | description:
98 | "A tool for generating an image and posting a tweet to Twitter. It takes an " +
99 | "object as a parameter with `tweet` and `imagePrompt` fields. The `tweet` field " +
100 | "contains the tweet to post which is a string, and the `imagePrompt` field contains " +
101 | "the prompt to generate an image for the tweet which is a string. You absolutely " +
102 | "should not give the strings directly as parameters.",
103 | schema: z.object({
104 | tweet: z.string().describe("The tweet to post."),
105 | imagePrompt: z
106 | .string()
107 | .describe("The prompt to generate an image for the tweet."),
108 | }),
109 | invoke: async ({
110 | tweet,
111 | imagePrompt,
112 | }: {
113 | tweet: string;
114 | imagePrompt: string;
115 | }) => {
116 | const acquired = await context.run(
117 | "deduplicate tweets",
118 | async () => {
119 | const redis = Redis.fromEnv();
120 | const acquired = await redis.set(
121 | `${context.workflowRunId}:tweet:deduplicator`,
122 | "true",
123 | {
124 | nx: true,
125 | ex: TWEET_DEDUPLICATOR_TIMEOUT,
126 | }
127 | );
128 | return acquired === "OK";
129 | }
130 | );
131 |
132 | if (!acquired) {
133 | return "deduplicated";
134 | }
135 |
136 | const { body: ideogramResult } =
137 | await context.call(
138 | "call image generation API",
139 | {
140 | url: "https://api.ideogram.ai/generate",
141 | method: "POST",
142 | body: {
143 | image_request: {
144 | model: "V_2",
145 | prompt: imagePrompt,
146 | aspect_ratio: "ASPECT_16_9",
147 | magic_prompt_option: "AUTO",
148 | style_type: "DESIGN",
149 | color_palette: {
150 | members: [
151 | { color_hex: "#FF6D00" },
152 | { color_hex: "#FFCA12" },
153 | { color_hex: "#58BAE7" },
154 | { color_hex: "#DDDDDD" },
155 | ],
156 | },
157 | },
158 | },
159 | headers: {
160 | "Content-Type": "application/json",
161 | "Api-Key": process.env.IDEOGRAM_API_KEY!,
162 | },
163 | }
164 | );
165 |
166 | const twitterResult = context.run(
167 | "post image to Twitter",
168 | async () => {
169 | const client = new TwitterApi({
170 | appKey: process.env.TWITTER_CONSUMER_KEY!,
171 | appSecret: process.env.TWITTER_CONSUMER_SECRET!,
172 | accessToken: process.env.TWITTER_ACCESS_TOKEN,
173 | accessSecret: process.env.TWITTER_ACCESS_TOKEN_SECRET,
174 | }).readWrite;
175 | const blob = await fetch(ideogramResult.data[0].url).then(
176 | (res) => res.blob()
177 | );
178 | const arrayBuffer = await blob.arrayBuffer();
179 | const buffer = Buffer.from(arrayBuffer);
180 | const mediaId = await client.v1.uploadMedia(buffer, {
181 | mimeType: "image/jpeg",
182 | });
183 | await client.v2.tweet(tweet, {
184 | media: { media_ids: [mediaId] },
185 | });
186 | return tweet;
187 | }
188 | );
189 | return twitterResult;
190 | },
191 | executeAsStep: false,
192 | }),
193 | },
194 | background:
195 | "You are an AI assistant that helps people stay up-to-date with the latest news. " +
196 | "You can fetch the top 1 unvisited Hacker News article and post it to Twitter " +
197 | "using the `hackerNewsTool` and `twitterTool` tools respectively. You will be " +
198 | "called every hour to fetch a new article and post it to Twitter. You must create " +
199 | "a 250 character tweet summary of the article. Provide links in the tweet if " +
200 | "possible. Make sure to generate an image related to the tweet and post it along " +
201 | "with the tweet.",
202 | });
203 |
204 | const task = context.agents.task({
205 | agent: hackerNewsTwitterAgent,
206 | prompt:
207 | "Fetch the top 1 unvisited Hacker News article and post it to Twitter. Generated image will be posted " +
208 | "to Twitter with the tweet so it should be related to the tweet. Sometimes the articles are " +
209 | "written in first person, so make sure to change the first person to third person point of view in tweet. " +
210 | "Do not change the urls in the tweet. Do not post inappropriate content in tweet or " +
211 | "image. Make sure the tweet is short and concise, has no more than 250 characters. Generate " +
212 | "a visually appealing illustration related to the article. The image " +
213 | "should be clean, simple, and engaging—ideal for social media scrolling. Use an isometric " +
214 | "or minimal flat design style with smooth gradients and soft shadows. Avoid clutter, excessive " +
215 | "details, or small text. If the image includes arrows or lines, make them slightly thick and " +
216 | "black for clarity. Do not include logos or branding. The illustration should convey the article’s " +
217 | "theme in a creative and inviting way. Try to give a concrete description of the image. In the " +
218 | "tweet, make sure to put the url of the article two lines below the tweet, with Check it out " +
219 | "here or similar expression before it. Do not call a tool twice. Only generate one image, only post one tweet." +
220 | "Do not generate multiple images or post multiple tweets. Always include the url of the article in the tweet.",
221 | });
222 |
223 | const sleepTime = await context.run("randomize sleep time", async () => {
224 | return Math.floor(
225 | Math.random() * (MAX_SLEEP_TIME - MIN_SLEEP_TIME) + MIN_SLEEP_TIME
226 | );
227 | });
228 |
229 | await context.sleep("sleep", sleepTime);
230 |
231 | await task.run();
232 | },
233 | {
234 | retries: 0,
235 | }
236 | );
237 |
--------------------------------------------------------------------------------