Read daily.dev from your terminal
Too busy to read?
Get an AI summary
On the previous page we set up a Personal Access Token and made a first request against the daily.dev API. Curl one-liners are fine for poking at endpoints, but you don't want to retype them every morning. So let's build a small CLI called dailydev in TypeScript.
This is a build-along tutorial. We implement two commands, feed and popular, and stop there. Once they work you have every piece you need (argument parsing, validation, a typed client, output rendering) and the remaining commands follow the same pattern.
The finished project is at dailydotdev/dd-cli-example. The snippets below cover the parts that need explaining rather than every line, so clone the repo if you prefer reading the whole thing at once, or build it up as you go.
Two design goals:
- Human-friendly by default: readable output when you run it yourself
- Agent-friendly on demand: a
--jsonflag that prints the raw API response, so a coding agent can run the same tool
The second goal matters more than it looks. Once the CLI can print JSON, your agent doesn't need an SDK, an MCP server, or any integration work. It runs the commands you already use.
Setup
We use parseArgs from Node's standard library for arguments and zod for validation. That's the same combination we use for the bin scripts in our own API. No CLI framework, no big dependency tree.
mkdir dailydev-cli && cd dailydev-cli && pnpm init
pnpm add zod
pnpm add -D typescript tsx @types/node
Set "type": "module" in package.json and add a script to run it:
{
"type": "module",
"scripts": {
"dailydev": "tsx src/dailydev.ts"
}
}
Run it with pnpm dailydev <command>, which forwards flags as they are. With npm you need npm run dailydev -- feed -n 3. Without the --, npm swallows the flags and you get the defaults without any warning.
Make sure your token is in the environment:
export DAILY_DEV_TOKEN="dda_your_token_here"
The schemas
Start by describing the data. Two zod schemas cover the API (one post, one feed response) and a third describes the CLI's own arguments. It costs a few lines and gives you real types through z.infer, plus a clear failure if a response shape ever changes.
src/schema.ts:
import { z } from 'zod';
export const postSchema = z.object({
id: z.string(),
title: z.string(),
url: z.string(),
summary: z.string().nullish(),
tags: z.array(z.string()).nullish(),
readTime: z.number().nullish(),
numUpvotes: z.number(),
numComments: z.number(),
});
export const feedSchema = z.object({
data: z.array(postSchema),
pagination: z.object({
hasNextPage: z.boolean(),
endCursor: z.string().nullish(),
}),
});
export const argsSchema = z.object({
command: z.enum(['feed', 'popular']),
limit: z.coerce.number().int().min(1).max(50),
json: z.boolean(),
});
export type Post = z.infer<typeof postSchema>;
We hand-write these because two schemas are easier to read in an article than generated output. In a real project you probably wouldn't. The API publishes an OpenAPI spec, and plenty of libraries turn a spec into zod schemas or a fully typed client, so the definitions stay in sync with the API instead of drifting the first time a field is added. Look into that before you hand-model the rest of the surface.
The client
One authenticated fetch is the whole client. Paths and query strings go through URL and URLSearchParams rather than template literals, so escaping is handled for you. That matters as soon as you add a search command and someone types a query with spaces in it.
src/api.ts:
export const request = async (
path: string,
params: Record<string, string> = {},
): Promise<unknown> => {
const token = process.env.DAILY_DEV_TOKEN;
if (!token) {
throw new Error(
'Set DAILY_DEV_TOKEN, generate one at https://daily.dev/settings/api',
);
}
const url = new URL(path, API);
url.search = new URLSearchParams(params).toString();
const res = await fetch(url, {
headers: { Authorization: `Bearer ${token}` },
});
if (res.status === 429) {
throw await rateLimitError(res);
}
if (!res.ok) {
const body = (await res.json().catch(() => ({}))) as { message?: string };
throw new ApiError(
res.status,
body.message ?? `${res.status} ${res.statusText}`,
);
}
return res.json();
};
export const fetchFeed = (path: string, limit: number) =>
request(path, { limit: String(limit) });
One URL detail: the base (new URL('https://api.daily.dev/public/v1/')) ends in a slash and the paths you pass are relative (feeds/foryou, not /feeds/foryou). A leading slash resolves from the host root and drops the /public/v1 prefix.
Notice what fetchFeed returns: the untouched payload, typed unknown. We validate only where we render, which keeps the --json output identical to what the API sent. If you parse on the way through, zod's default behaviour strips every field your schema doesn't mention (source, createdAt, anything added later), and that is often the data an agent wants.
ApiError is a three-line class that keeps the status next to the message, enough for a caller to tell a 403 from a 404 without matching on strings. The rateLimitError helper is a few more lines you can read in the repo. It turns the response into a RateLimitError carrying the API's own message plus retryAfter and reset, both parsed from the headers with the same zod schemas as everything else. A 429 gets its own treatment because it is the one error you should expect to hit, and the only one the API tells you how to recover from:
{
"statusCode": 429,
"error": "rate_limit_exceeded",
"message": "Free tier quota exhausted. Plus raises this limit - see https://app.daily.dev/plus"
}
Passing that message through means your CLI says what happened rather than a generic "request failed". Keeping the numbers on the error object means a script or an agent can back off without parsing prose.
Rendering
The human-facing half: upvotes, comments, read time, title, then the id and URL on an indented second line.
src/utils.ts:
import type { Post } from './schema.ts';
export const render = (posts: Post[]) => {
for (const post of posts) {
console.log(
`▲${post.numUpvotes}\t💬${post.numComments}\t${post.readTime ?? '?'}m\t${post.title}`,
);
console.log(`\t${post.id} ${post.url}\n`);
}
};
The command
Now wire it together. parseArgs handles the mechanics (flags, short aliases, positionals) and zod decides whether the result is usable. Splitting it this way means a bad --limit fails with a readable message instead of an undefined three functions later.
src/dailydev.ts:
import { parseArgs } from 'node:util';
import { fetchFeed } from './api.ts';
import { argsSchema, feedSchema } from './schema.ts';
import { render } from './utils.ts';
const feeds = {
feed: 'feeds/foryou',
popular: 'feeds/popular',
};
const main = async () => {
try {
const { values, positionals } = parseArgs({
allowPositionals: true,
options: {
limit: { type: 'string', short: 'n', default: '10' },
json: { type: 'boolean', default: false },
},
});
const result = argsSchema.safeParse({
command: positionals[0] ?? 'feed',
limit: values.limit,
json: values.json,
});
if (result.error) {
throw new Error(
`Error '${result.error.issues[0].path}': ${result.error.issues[0].message}`,
);
}
const { command, limit, json } = result.data;
const feed = await fetchFeed(feeds[command], limit);
if (json) {
console.log(JSON.stringify(feed, null, 2));
} else {
render(feedSchema.parse(feed).data);
}
} catch (error) {
console.error((error as Error).message);
process.exit(1);
}
};
main();
Four short files, one dependency, and the tool is done.
Using it
$ pnpm dailydev feed -n 3
▲342 💬28 6m What actually changed in Postgres 18
abc123 https://...
▲198 💬41 9m We migrated off Kubernetes and lived to tell the tale
def456 https://...
▲87 💬12 4m TypeScript 6.0 beta: what breaks
ghi789 https://...
pnpm dailydev popular gives you the same view of what's trending platform-wide. Mistype the command and zod tells you what it expected. Ask for -n 500 and it stops you at the API's ceiling instead of sending a request that will fail.
Now let your agent drive it
This is what the --json flag is for. Your agent doesn't need to know anything about the daily.dev API. It only needs to know dailydev exists. Tell it:
I have a CLI in this directory for reading daily.dev (my developer news
feed). Run `pnpm dailydev feed` for my personalized feed and
`pnpm dailydev popular`
for what's trending. Add --json to any command for machine-readable
output, and -n <count> to change how many posts come back.
Then you can ask things like:
- "Anything about Postgres in my feed today? Summarize the top pick." The agent runs
pnpm dailydev feed -n 20 --jsonand filters by tags. - "Compare what my feed says about Bun with what's trending platform-wide." The agent runs
pnpm dailydev feed --jsonandpnpm dailydev popular --jsonand compares the two.
You get a readable terminal reader. Your agent gets a structured data source with engagement numbers (numUpvotes, numComments, readTime) it can rank and reason over, which a generic web scrape doesn't have.
Extending it
Everything else in the API follows the pattern you just wrote: add a path, widen the command enum, reuse or extend a schema. Some obvious next commands:
| Command | Endpoint | What's new about it |
|---|---|---|
dailydev tag <tag> |
GET /feeds/tag/{tag} |
a second positional argument to validate |
dailydev search <query> |
GET /search/posts?q= |
URL-encoding a multi-word query |
dailydev read <id> |
GET /posts/{id} |
a single-post schema, with the AI summary |
dailydev tags |
GET /tags |
the tags available to filter by |
Clone the example repo and these make good first pull requests to yourself. Its main branch is ahead of this page: discussed, comments and the bookmark commands are already in there, because the next two posts use them. dailydev read is the one that changes your morning. Pull the AI summary of a post before committing to the full article, then keep the ones you want. And since your agent already knows how to run the tool, every command you add is something it can use right away, with no extra integration work.
How much you get
A terminal habit fits comfortably in a free account: a feed check with coffee, a couple of reads, a search when something is bugging you.
Automation is where it gets tight. Point an hourly job at your feed and you run out of requests long before the month ends. Plus comes with much higher rate limits, enough that you stop counting.
What's next
dailydev reads your feed when you ask it to. Next we make it proactive: a scheduled agent that reads your feed, the most discussed posts, and the comment threads you should know about overnight, then delivers a morning briefing before you open a single tab.
Next: Build your own morning briefing, the same API on a schedule, with an editorial prompt doing the reading for you.
- Example repo: github.com/dailydotdev/dd-cli-example
- Previous: Welcome to the daily.dev API
- API reference: api.daily.dev/public/v1/docs/json
- Docs: docs.daily.dev/public-api