For AI agents: markdown of this page — /docs-content-en/infra/jobs/daily-digest.md documentation index — /llms.txt
Daily digest
Goal: every day at 09:00 Africa/Abidjan, accept a request at /tasks/digest?mode=daily and enqueue digest generation durably in the app. Create a job with the settings below and store the returned secret in the handler's protected configuration. This is a configuration example, not confirmation of your account's availability.
{
"cronExpr": "0 9 * * *",
"timezone": "Africa/Abidjan",
"path": "/tasks/digest?mode=daily",
"label": "daily digest",
"enabled": true
}
Handler
Put the verifier and HTTP reader from the signature page into verifier.ts and http-adapter.ts. Create the accepted_runs/job_outbox tables from that page. This example requires pg. Set EXPECTED_JOB_ID, JOB_SIGNING_SECRET and DATABASE_URL on the server; never send them to the browser. The endpoint must be reachable through trusted platform ingress.
import { createServer } from 'node:http';
import { Pool } from 'pg';
import { verifyJobRequest } from './verifier.js';
import { readRawBody, singleHeader } from './http-adapter.js';
const secret = process.env.JOB_SIGNING_SECRET;
const expectedJobId = process.env.EXPECTED_JOB_ID;
if (!secret || !expectedJobId) throw new Error('Missing handler configuration');
const pool = new Pool({ connectionString: process.env.DATABASE_URL });
createServer(async (req, res) => {
if (req.method !== 'POST' || req.url !== '/tasks/digest?mode=daily') {
res.writeHead(404).end(); return;
}
try {
const rawBody = await readRawBody(req);
if (!verifyJobRequest({ callerKind: singleHeader(req, 'X-Vibe-Caller-Kind'),
singleSignatureHeader: singleHeader(req, 'Vibe-Signature'), rawBody,
secret, nowSeconds: Math.floor(Date.now() / 1000) })) {
res.writeHead(401).end(); return;
}
let p;
try { p = JSON.parse(rawBody.toString('utf8')); }
catch { res.writeHead(400).end(); return; }
if (!p || p.type !== 'scheduled_job.run' || p.jobId !== expectedJobId ||
typeof p.runId !== 'string' || p.runId.length < 1 || p.runId.length > 200 ||
typeof p.scheduledFor !== 'string' || !/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/.test(p.scheduledFor) ||
!Number.isFinite(Date.parse(p.scheduledFor)) || !Number.isInteger(p.attempt) ||
p.attempt < 1 || p.attempt > 4 || typeof p.late !== 'boolean' ||
!['schedule', 'manual'].includes(p.trigger)) {
res.writeHead(400).end(); return;
}
const db = await pool.connect();
try {
await db.query('BEGIN');
await db.query(`WITH accepted AS (
INSERT INTO accepted_runs(run_id,response_status,response_body)
VALUES ($1,202,'{"accepted":true}'::jsonb)
ON CONFLICT (run_id) DO NOTHING RETURNING run_id
) INSERT INTO job_outbox(run_id,payload)
SELECT run_id,$2::jsonb FROM accepted`, [p.runId, JSON.stringify(p)]);
const saved = await db.query(
'SELECT response_status,response_body FROM accepted_runs WHERE run_id=$1', [p.runId]);
if (saved.rows.length !== 1) throw new Error('Missing acceptance receipt');
await db.query('COMMIT');
res.writeHead(saved.rows[0].response_status, { 'Content-Type': 'application/json' });
res.end(JSON.stringify(saved.rows[0].response_body));
} catch {
await db.query('ROLLBACK');
res.writeHead(503).end();
} finally { db.release(); }
} catch (error) {
res.writeHead(error instanceof Error && error.message === 'BODY_TOO_LARGE' ? 413 : 503).end();
}
}).listen(3000);
Work after the HTTP response
202 means runId and work were recorded in one transaction. Repeating that runId returns the saved 202 without adding work. An app worker processes job_outbox separately, with durable claiming and crash recovery; use runId as the idempotency key when sending the digest to an external service. Mark processed_at in both the outbox and receipt after completion. Keep receipts for at least the configured history retention (90 days by default, at least 7); do not remove unfinished work.
Run history shows SUCCEEDED after HTTP 202 even if generation is still running. Expose completion in your app's own status or notify the user from the worker. The server's ordinary idle timer does not guarantee protection until background work completes: choose the run mode and job duration accordingly. A manual run is useful for testing, but every POST creates separate work.