What you will build
A daily AI news digest that Codex researches for you, with rules your own code enforces. Codex runs read-only with live web search and must answer in a JSON shape you define. New releases must link to an official vendor page on your allowlist. Community stories and repos must be picked from a candidate list you collected, by id, so Codex cannot invent a link. A script validates everything, checks every link, writes one file per day, and runs every morning on a schedule.
This is how the news page on dainer.ai is produced. The site started with "write a file, a human deploys it" and later added an authenticated endpoint that stores each digest in the site database. Both publishing paths are in Step 9.
Before you start
- Node.js 22 (the site pins
>=22.16) and a project with ascripts/folder. - The Codex CLI, installed and signed in. Check with
codex --version. We used codex-cli 0.154. - A Mac if you want the launchd schedule in Step 8. On Linux, cron does the same job.
- Optional: a folder of collected candidates (Hacker News, GitHub trending and similar) as JSON files, if you want community and repo sections.
- About one to two hours for the first working run and the schedule.
How it works
Treat the model as a researcher whose output is untrusted until checked. Codex does the searching, reading and summarising. Your script does everything that must be exact: the shape, the allowed sources, the URLs, the dates and the final file.
- A shared rules module defines the allowed domains, sections and tool tags, and a
validateDigestfunction. The site uses the same module to load digests, so a bad file fails the build. - The script collects candidates for the community and repo sections and gives each one an id.
- It runs
codex execread-only with--searchand an--output-schemafile, so the final answer must be JSON in your shape. - For candidate sections it swaps Codex's URL for the URL stored under the chosen id. Unknown ids are dropped.
- It validates the digest, fetches every link, drops dead ones, and validates again.
- It writes
content/news/YYYY-MM-DD.jsonand, if configured, posts the digest to the site's ingest endpoint. - A launch agent runs it every morning at 07:30 from a folder macOS allows background jobs to read.
Step 1: Write the rules module and test it
One file holds the rules, so the pipeline, the page loader and the ingest endpoint cannot drift apart.
// src/lib/news-rules.mjs (trimmed)
export const NEWS_DOMAINS = ["anthropic.com", "claude.com", "openai.com", "blog.google", "github.blog", "huggingface.co" /* ... */];
export const NEWS_SECTIONS = { releases: "New releases", community: "What builders are discussing", repos: "Repos & skills", voices: "On X" };
export const SECTION_KEYS = Object.keys(NEWS_SECTIONS);
export function officialUrl(url) {
try {
const u = new URL(url);
if (u.protocol !== "https:") return false;
return NEWS_DOMAINS.some((d) => u.hostname === d || u.hostname.endsWith("." + d));
} catch {
return false;
}
}validateNewsItem then checks each item: a known section, a title of 8 to 160 characters, an official URL for releases and an https URL for the rest, a source, a YYYY-MM-DD date, a summary of 30 to 300 characters, and tool tags from a fixed list. validateDigest checks the date and 1 to 20 items, runs every item through the item check, and rejects duplicate URLs.
The hostname test matters. endsWith("." + d) accepts www.anthropic.com but rejects openai.com.evil.example, which a plain includes check would let through. The site's tests cover it:
assert.equal(officialUrl("https://www.anthropic.com/news/x"), true);
assert.equal(officialUrl("http://openai.com/index/x"), false, "http rejected");
assert.equal(officialUrl("https://openai.com.evil.example/x"), false, "suffix trick rejected");
assert.equal(officialUrl("https://techcrunch.com/x"), false, "news site rejected");Check
node --test tests/news-rules.mjspasses. It printed 3 of 3 passing on the site.- A digest with an
http://link, a duplicate URL or a 10-character summary throws with a clear message.
Step 2: Define the output schema
codex exec --output-schema <file> asks for a final response that matches a JSON Schema. The Codex docs say the schema should list required fields and set additionalProperties to false. The site's schema reuses the rules module, so enums cannot go out of date:
const schema = {
type: "object", additionalProperties: false, required: ["items"],
properties: {
items: {
type: "array", minItems: 3, maxItems: 14,
items: {
type: "object", additionalProperties: false,
required: ["section", "candidate_id", "url", "title", "source", "date", "summary", "tools"],
properties: {
section: { type: "string", enum: SECTION_KEYS },
candidate_id: { type: "string" }, url: { type: "string" }, title: { type: "string" },
source: { type: "string" }, date: { type: "string" }, summary: { type: "string" },
tools: { type: "array", items: { type: "string", enum: NEWS_TOOLS } },
},
},
},
},
};Every field is required, including candidate_id. Releases send an empty string there. That keeps one flat shape instead of optional fields the model may skip.
Check
- The schema is written to a temporary file and passed by path.
- Every enum comes from the rules module, not a copied list.
Step 3: Write the prompt rules that matter
The prompt is plain text built in the script. These are the rules that did the work:
1. releases (up to 4): use web search to find announcements from the last 7 days on these official domains only: [DOMAINS].
Use the exact article or changelog URL, open it to confirm it says what you summarise, set candidate_id to "".
2. community (up to 3): pick from COMMUNITY CANDIDATES only. Set candidate_id to the candidate id.
3. repos (up to 4): pick from REPO CANDIDATES only (candidate_id required). Skip anything that looks like spam, crypto hype or a thin wrapper.
4. voices (up to 2): pick from X CANDIDATES only, if any exist; otherwise return none.
For every item: title in plain words (max 120 chars), summary 1-2 sentences in your own words (max 240 chars): what it is and who it helps. No hype, no quotes, no invented numbers.Below the rules, the script appends each candidate list as lines of id | date | score | title | url. The limits in the prompt sit below the validator's limits, so a slightly long answer still passes.
Check
- The prompt names the date, the domains and the candidate lists. Nothing refers to "the usual sources".
- The prompt limits are stricter than the validator limits.
Step 4: Run Codex read-only with search and a schema
The script calls Codex with spawnSync from Node:
const r = spawnSync(
"codex",
["--search", "exec", "--skip-git-repo-check", "-s", "read-only", "-C", tmp,
"--output-schema", schemaFile, "-o", lastFile, prompt],
{ stdio: ["ignore", "ignore", "pipe"], encoding: "utf8", timeout: 15 * 60 * 1000 },
);
if (r.status !== 0 || !existsSync(lastFile)) process.exit(1);What each flag does, from the Codex CLI docs:
--searchturns on live web search (instead of the default cached mode). It is a top-level flag, so it goes beforeexec.execruns Codex without the interactive screen. It streams progress to stderr and prints the final message to stdout.-s read-onlysets the sandbox for commands the model runs. It is theexecdefault, and writing it out makes the intent clear.--skip-git-repo-checkallows running outside a Git repository. Codex otherwise requires one. The script runs in an empty temporary folder given with-C.-owrites the final message to a file, which the script then parses.
A 15-minute timeout stops a stuck run.
Check
- Run the script by hand once and read the stderr it prints on failure.
- The temporary folder is removed in a
finallyblock.
Step 5: Make links impossible to invent
For every section except releases, the script ignores the URL Codex returns and uses the one stored under the candidate id:
for (const it of picked) {
if (it.section !== "releases") {
const c = candidates.get(it.candidate_id);
if (!c || c.kind !== it.section) { drop("not from candidates", it); continue; }
it.url = c.url;
if (!/^\d{4}-\d{2}-\d{2}$/.test(it.date)) it.date = c.date;
}
delete it.candidate_id;
items.push(it);
}An id that does not exist, or one taken from the wrong section, is dropped and logged. Releases do not come from a list, so they rely on the official-domain rule and the live link check instead.
Candidates are collected before Codex runs. The script reads JSON files from a local collector folder, keeps them only if the file is under 48 hours old, filters Hacker News items with a keyword pattern for AI topics, keeps the top 25 by score, and removes duplicate URLs.
Check
- Change one candidate id in a saved Codex answer and rerun the selection step. The item is dropped with
not from candidates. - The log line lists how many candidates each section had and how old each source file was.
Step 6: Validate, then check every link
Validate once to catch shape problems, then fetch every link and validate the survivors again:
for (const it of digest.items) {
if (it.section === "voices") { ok.push(it); continue; } // X blocks anonymous fetches
const res = await fetch(it.url, { redirect: "follow", signal: AbortSignal.timeout(20000),
headers: { "User-Agent": "Mozilla/5.0 dainer-news" } }).catch(() => null);
const blocked = res && (res.status === 401 || res.status === 403) && it.section === "releases";
if (!res || (res.status >= 400 && !blocked)) { drop(`link ${res ? res.status : "no response"}`, it); continue; }
ok.push(it);
}
digest = validateDigest({ date: today, items: ok }, "checked output");The first version rejected the whole run if any link failed. The current one drops only the bad item, so one dead link does not cost a day.
Check
- The log shows each dropped item with its reason and HTTP status.
- The final file has at least one item, or the run fails loudly.
Step 7: Write the day file and load it on the server
The script writes content/news/YYYY-MM-DD.json using the Kuala Lumpur date, and skips the run if today's file exists unless you pass --force. On the site, a loader reads the folder, validates each file with the same validateDigest, and returns the newest 14 days. It uses node:fs, so it must only run on the server. The page, a server component, calls it and passes plain data to the list component.
Keep that boundary strict. Next.js lets a module be imported from both server and client code, and its docs suggest the server-only package to turn an accidental client import into a build error.
Check
npm run buildfails if a digest file is invalid. That is the point.- No file with
"use client"imports the loader.
Step 8: Schedule it outside the Desktop
A launch agent runs the job at 07:30 every day. The plist only calls a wrapper script:
<key>Label</key><string>ai.example.news-digest</string>
<key>ProgramArguments</key>
<array><string>/bin/zsh</string><string>__RUN__</string></array>
<key>StartCalendarInterval</key>
<dict><key>Hour</key><integer>7</integer><key>Minute</key><integer>30</integer></dict>
<key>RunAtLoad</key><false/>The wrapper sets PATH itself, because a launch agent does not read your shell profile and would not find node or codex. It also points the output folder away from the project and appends everything to a log:
HOME_DIR="$HOME/Library/Application Support/your-app"
export PATH="$HOME/.npm-global/bin:/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin"
export NEWS_OUT_DIR="$HOME_DIR/news"
{ echo "=== $(date) ==="; node "$HOME_DIR/news-digest/scripts/news-digest.mjs"; echo "exit $?"; } >> "$HOME_DIR/news-digest.log" 2>&1An install script copies the pipeline and the rules module into that folder, fills __RUN__ in the plist, and loads it with launchctl bootstrap gui/$(id -u) <plist>. Before each dev or build, a small sync script copies new, valid day files from the output folder into content/news. The launchd.plist man page notes that a calendar job missed while the Mac sleeps runs when it wakes.
Check
launchctl kickstart gui/$(id -u)/ai.example.news-digestruns the job now. The log ends withexit 0.npm run devprints that it copied the new digest.
Step 9: Choose how it gets published
There are two ways, and you can start with the first.
- File and deploy. The day file ships with your next deploy. Nothing reaches the public site until a person deploys. This is the safest start.
- An ingest endpoint. The site accepts
POSTrequests with the digest and stores each story in its database, keyed by URL so a story is never published twice. The script posts only when a secret is present.
If you build the endpoint, guard it like the site does: a server-only secret of at least 40 characters, a constant-time comparison of SHA-256 hashes of the bearer token and the secret, a rate limit of 30 requests an hour, a 64 KB size limit, and the same validateDigest before anything is stored.
const same = (a: string, b: string) =>
timingSafeEqual(createHash("sha256").update(a).digest(), createHash("sha256").update(b).digest());
if (!auth.startsWith("Bearer ") || !same(auth.slice(7), secret)) throw new HttpError(401, "Not authorised.");Keep the secret in an environment variable or a file outside the repo. Never pass it on the command line.
Check
- A request without the token returns 401; an oversized body returns 413; an invalid digest returns 400.
- The same digest posted twice inserts nothing the second time.
Gotchas we hit
- launchd could not read the Desktop. The first scheduled run exited with code 127. A probe showed
Operation not permitted: macOS protects the Desktop, Documents and Downloads folders, and a background job has no access. Running from~/Library/Application Support/...fixed it. - Official sites block scripts. Some vendor pages answer 401 or 403 to an automated fetch. Releases already pass the domain allowlist, so the check now accepts those two codes for releases and still drops a missing page.
- X blocks anonymous fetches. Posts from X skip the live check; the candidate id is their proof.
- A client component imported the file loader. In the library build, a client component pulling in a
node:fsloader broke the page with a 500. Splitting labels and types into a file with no Node imports fixed it. - Codex expects a Git repo. Running in a temporary folder needs
--skip-git-repo-check.
Take it further
- Add sections one at a time, each with its own proof rule: allowlist, candidate id or both.
- Write a weekly summary that reads seven day files and asks Codex for a short recap, with the same schema and link checks.
- Read Set up a project for Claude Code for permission rules that stop an agent from deploying on its own.
- Read AI-assisted visual QA to check the news page in both themes after each change.
Quick checklist
- One rules module is shared by the script, the loader and the endpoint.
- The allowlist test rejects
http, lookalike suffixes and news sites. - Codex runs read-only with
--search,--output-schemaand-o. - Community and repo links come from candidate ids, never from the model.
- Every link is fetched; dead ones are dropped and logged.
- Day files are validated at build time by the same function.
- The schedule runs from a folder outside Desktop, with a full PATH and a log.
- Publishing is either a human deploy or an endpoint guarded by a server-only secret.