This guide is written for the AI agents (Openclaw, Hermes) that produce the daily morning briefing. It covers how to authenticate with the service token, the full ingest payload contract (schema_version: 1), how to schedule the cronjob, and the operational rules the agent must follow.

Overview

The Go backend stores one morning routine report per user per date. The agent generates the report (news, trends, ideas, top stats, focus, inbox highlights, mindset quote) and POSTs it once a day. Users read it on the panel dashboard at http://[DOMAIN_NAME]/app/morning-routine.
  • Endpoint: POST /api/morning-routine/ingest
  • Auth: Authorization: Bearer <MORNING_ROUTINE_INGEST_TOKEN> — a shared service token, not a user session
  • Write model: full-replace upsert keyed on (user, report_date) — every POST is the complete report for that day
  • Flag behavior: ingest is not feature-flag gated. It always stores; the morning_routine flag only controls whether users can read the report

Authentication

The route has no session middleware. It is authenticated solely by the bearer secret configured on the server as MORNING_ROUTINE_INGEST_TOKEN (in backend/go/.env):
Authorization: Bearer <token>
  • The server compares tokens in constant time and never logs the secret.
  • If the server has no token configured, the route is disabled (every request gets 401).
  • A missing, malformed, or wrong token returns 401 and nothing is stored.

Ingest payload (contract, schema_version: 1)

The body is a single JSON object, max 1 MB. Every list section is capped at 20 items.

Top-level fields

FieldTypeRequiredNotes
schema_versionint✅ must be 1Any other value → 422
userstringTarget user: an email or a Better Auth user_id. Unknown user → 404
report_datestringYYYY-MM-DD, the date the report is for (in the user’s timezone)
timezonestringrecommendedIANA zone, e.g. Asia/Kuala_Lumpur; used to render the freshness stamp
time_labelstringoptionalHuman label, e.g. "Wednesday, 11 June"
location_labelstringoptionale.g. "Kuala Lumpur, MY"
generated_atstringrecommendedRFC 3339 timestamp of when the agent generated the report
top_statsarrayoptionalSee Top stats
mindsetobjectoptionalSee Mindset
focusobjectoptionalSee Focus
newsarrayoptionalSee News
inboxarrayoptionalSee Inbox
trendsarrayoptionalSee Trends
ideasarrayoptionalSee Ideas
All section fields are optional — omit a section the agent didn’t produce that day. But beware: because the POST is a full replace, omitting a section also removes it from a previously stored report for the same date (see Write semantics).

Section shapes

top_stats

Small stat cards (weather, FX, agenda count, …). key is the stable identifier users pin/hide in their card preferences — keep keys consistent day to day.
{ "key": "weather", "icon": "🌤️", "label": "Weather", "value": "31°C", "sub": "Partly cloudy", "link": "https://..." }
FieldRequiredNotes
keyStable snake_case id, e.g. datetime, weather, sunrise_sunset, agenda, air_quality, fx_usd_myr, open_prs, markets, reminders. New keys are allowed — they appear appended on the dashboard
icon, label, value, sub, linkoptionalDisplay strings; link must be http/https

mindset

{ "quote": "Slow is smooth, smooth is fast.", "author": "Unknown" }

focus

The single most important thing for the day.
{ "headline": "Ship PR #44", "detail": "Codex review round done; merge after #43.", "link": "https://..." }

news

{ "rank": 1, "title": "…", "summary": "…", "url": "https://…", "source": "Hacker News", "points": 512, "posted_relative": "3h ago" }
title is required per item; the rest are optional.

inbox

Email highlights worth the user’s attention.
{ "from": "GitHub", "subject": "…", "summary": "…", "url": "https://…", "priority": "high" }
subject is required per item.
{ "rank": 1, "title": "…", "why_it_matters": "…", "what_to_learn": "…", "full_content_md": "## Markdown body…" }
title is required. full_content_md is Markdown rendered in a detail drawer — keep it self-contained.

ideas

{ "rank": 1, "name": "…", "one_liner": "…", "target": "indie devs", "features": ["…", "…"], "full_content_md": "## Markdown body…" }
name is required.

Full example

curl -sS -X POST "https://<go-api-host>/api/morning-routine/ingest" \
  -H "Authorization: Bearer $MORNING_ROUTINE_INGEST_TOKEN" \
  -H "Content-Type: application/json" \
  -d @- <<'JSON'
{
  "schema_version": 1,
  "user": "cheahkokweng@gmail.com",
  "report_date": "2026-06-11",
  "timezone": "Asia/Kuala_Lumpur",
  "time_label": "Thursday, 11 June",
  "location_label": "Kuala Lumpur, MY",
  "generated_at": "2026-06-11T07:00:00+08:00",
  "top_stats": [
    { "key": "weather", "icon": "🌤️", "label": "Weather", "value": "31°C", "sub": "Partly cloudy" },
    { "key": "fx_usd_myr", "label": "USD/MYR", "value": "4.21" }
  ],
  "mindset": { "quote": "Slow is smooth, smooth is fast.", "author": "Unknown" },
  "focus": { "headline": "Ship PR #44", "detail": "Merge after backend PR #43." },
  "news": [
    { "rank": 1, "title": "Example headline", "summary": "One-paragraph summary.", "url": "https://example.com", "source": "Hacker News", "points": 512, "posted_relative": "3h ago" }
  ],
  "inbox": [
    { "from": "GitHub", "subject": "PR #44 review", "summary": "Codex requested changes.", "priority": "high" }
  ],
  "trends": [
    { "rank": 1, "title": "Example trend", "why_it_matters": "…", "what_to_learn": "…", "full_content_md": "## Details\nMarkdown body." }
  ],
  "ideas": [
    { "rank": 1, "name": "Example idea", "one_liner": "…", "target": "indie devs", "features": ["a", "b"], "full_content_md": "## Pitch\nMarkdown body." }
  ]
}
JSON
Success response:
{ "status": "stored" }

Write semantics (full-replace upsert)

  • At most one report exists per (user, report_date).
  • Every POST replaces the entire stored report for that date. There is no PATCH. If yesterday’s POST had news and today’s re-POST for the same date omits news, the stored report no longer has a news section.
  • Retries are idempotent: re-POSTing the identical complete payload leaves one row with the latest content.
  • The raw body is retained verbatim server-side (raw_payload) for future re-parsing — no need for the agent to keep its own archive for the backend’s sake.

Response codes

StatusMeaningStored?
200Report stored ({"status":"stored"})
401Missing/wrong bearer token (or token unconfigured server-side)
404user matched no Better Auth user
413Body over 1 MB
422Malformed JSON, missing user, bad report_date, missing/unsupported schema_version, a list section that is not a JSON array, or a list section over 20 items
500Server-side storage failure — safe to retry
Nothing is ever partially stored: any non-200 means the report was not written.

Scheduling the cronjob

Recommended pattern for Openclaw / Hermes:
  1. Run daily in the user’s timezone, early morning (e.g. 0 7 * * * at Asia/Kuala_Lumpur23 0 * * * UTC). The dashboard shows a “today pending” state until the report for the local date arrives.
  2. Set report_date to the user’s local date at generation time — not the UTC date. A report generated at 07:00 MYT on June 11 is "report_date": "2026-06-11" even though it is still June 10 in UTC.
  3. Generate the complete report first, then POST once. Do not POST sections incrementally — each POST overwrites the previous one.
  4. Retry on failure: on 5xx or network errors, retry the same payload with backoff (e.g. 3 attempts, 1/5/15 minutes). Retries are safe because the upsert is idempotent.
  5. Do not retry 4xx (other than fixing the payload): 401/404/422 indicate a config or contract problem that retrying will not fix — alert the operator instead.
  6. A late or corrected re-run is fine: re-POST the full corrected report for the same report_date and it cleanly replaces the earlier one.

Agent notes — DO / DON’T

DO

  • ✅ Send the complete report in a single POST per user per day.
  • ✅ Include schema_version: 1 in every payload.
  • ✅ Keep top_stats keys stable across days (weather today must be weather tomorrow) — user card preferences are keyed on them.
  • ✅ Use https URLs in link/url fields; the frontend drops unsafe schemes.
  • ✅ Set generated_at and timezone so users can see report freshness in their own zone.
  • ✅ Keep each list section at ≤ 20 items and the whole body under 1 MB (trim full_content_md first if you’re near the cap).
  • ✅ Treat 200 {"status":"stored"} as the only success signal.
  • ✅ Store the service token in the scheduler’s secret store and pass it only via the Authorization header.

DON’T

  • ❌ Don’t POST partial updates or per-section deltas — an omitted section is deleted from the stored report.
  • ❌ Don’t log, echo, or embed the ingest token anywhere (URLs, payloads, error reports). It belongs only in the Authorization header.
  • ❌ Don’t put the token in a query string or the body.
  • ❌ Don’t blind-retry 401/404/422 — escalate to the operator; the payload or config needs fixing.
  • ❌ Don’t invent new top-level payload fields — unknown fields are ignored today but are not part of the contract; propose a schema_version bump instead.
  • ❌ Don’t exceed 20 items per list section to “rank later” — the server rejects the whole report with 422.
  • ❌ Don’t use a future or UTC-shifted report_date; the dashboard keys “today” off the user’s local date.
  • ❌ Don’t assume the user can see the report after a 200 — read visibility is gated by the two-tier morning_routine feature flag (global + per-org). Ingest succeeding while the flag is off is expected.
  • Ingest HTTP adapter: backend/go/internal/modules/morningroutine/adapter/inbound/http/ingest_handler.go
  • Payload contract & use case: backend/go/internal/modules/morningroutine/application/usecase/ingest_report.go
  • Validation rules: backend/go/internal/modules/morningroutine/domain/entity/report.go
  • Spec: backend/go/openspec/changes/morning-routine-dashboard/specs/morning-routine-ingest/spec.md
  • Frontend consumer types: frontend/solidstart/apps/panel/src/features/morning-routine/api.ts