├── 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 | ![1-create-x-account](https://github.com/user-attachments/assets/1b5275fa-fd88-426e-a505-ce5f463ab3fe) 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 | ![2-create-x-developer-account](https://github.com/user-attachments/assets/b71838b9-c859-4ecc-9c60-ddbae4e6733f) 70 | 71 | 3. Fill the developer agreement & policy according to your needs. 72 | 73 | ![3-fill-developer-policy](https://github.com/user-attachments/assets/5cf65b86-cba7-48db-bffb-94ada369e31f) 74 | 75 | 4. Go to project settings. 76 | 77 | ![4-go-project-settings](https://github.com/user-attachments/assets/64253f99-14e5-4ff4-9877-a0f10f0e0530) 78 | 79 | 5. Set up User authentication settings. 80 | 81 | ![5-set-up-user-auth-settings](https://github.com/user-attachments/assets/2e7ad140-72fa-484d-9223-7c34e4d8c488) 82 | 83 | 6. Fill the form and save. 84 | 85 | ![6-user-auth-settings-form-part-1](https://github.com/user-attachments/assets/930c6ff6-85f0-4291-aba3-17f4e011e9e9) 86 | 87 | ![7-user-auth-settings-form-part-2](https://github.com/user-attachments/assets/52e2a5f9-7113-484c-9b1e-d651abbacd0c) 88 | 89 | 7. Make sure User authentication is set up. 90 | 91 | ![8-check-user-auth-settings-set-up](https://github.com/user-attachments/assets/99783d73-8564-48fb-8e5a-3c5e7727ae38) 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 | ![9-keys-and-tokens](https://github.com/user-attachments/assets/7af449da-a41c-4991-975d-9cf562859000) 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 | ![11-qstash-publish](https://github.com/user-attachments/assets/391314ed-9bc3-4fba-9852-c85314bf6671) 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 | ![10-qstash-schedule](https://github.com/user-attachments/assets/fb23d03e-3faf-4738-b2aa-0fc694512b10) 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 | --------------------------------------------------------------------------------