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 athttp://[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_routineflag 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 asMORNING_ROUTINE_INGEST_TOKEN (in backend/go/.env):
- 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
401and 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
| Field | Type | Required | Notes |
|---|---|---|---|
schema_version | int | ✅ must be 1 | Any other value → 422 |
user | string | ✅ | Target user: an email or a Better Auth user_id. Unknown user → 404 |
report_date | string | ✅ | YYYY-MM-DD, the date the report is for (in the user’s timezone) |
timezone | string | recommended | IANA zone, e.g. Asia/Kuala_Lumpur; used to render the freshness stamp |
time_label | string | optional | Human label, e.g. "Wednesday, 11 June" |
location_label | string | optional | e.g. "Kuala Lumpur, MY" |
generated_at | string | recommended | RFC 3339 timestamp of when the agent generated the report |
top_stats | array | optional | See Top stats |
mindset | object | optional | See Mindset |
focus | object | optional | See Focus |
news | array | optional | See News |
inbox | array | optional | See Inbox |
trends | array | optional | See Trends |
ideas | array | optional | See Ideas |
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.
| Field | Required | Notes |
|---|---|---|
key | ✅ | Stable 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, link | optional | Display strings; link must be http/https |
mindset
focus
The single most important thing for the day.
news
title is required per item; the rest are optional.
inbox
Email highlights worth the user’s attention.
subject is required per item.
trends
title is required. full_content_md is Markdown rendered in a detail drawer — keep it self-contained.
ideas
name is required.
Full example
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
newsand today’s re-POST for the same date omitsnews, 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
| Status | Meaning | Stored? |
|---|---|---|
200 | Report stored ({"status":"stored"}) | ✅ |
401 | Missing/wrong bearer token (or token unconfigured server-side) | ❌ |
404 | user matched no Better Auth user | ❌ |
413 | Body over 1 MB | ❌ |
422 | Malformed 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 | ❌ |
500 | Server-side storage failure — safe to retry | ❌ |
Scheduling the cronjob
Recommended pattern for Openclaw / Hermes:- Run daily in the user’s timezone, early morning (e.g.
0 7 * * *atAsia/Kuala_Lumpur→23 0 * * *UTC). The dashboard shows a “today pending” state until the report for the local date arrives. - Set
report_dateto 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. - Generate the complete report first, then POST once. Do not POST sections incrementally — each POST overwrites the previous one.
- Retry on failure: on
5xxor network errors, retry the same payload with backoff (e.g. 3 attempts, 1/5/15 minutes). Retries are safe because the upsert is idempotent. - Do not retry
4xx(other than fixing the payload):401/404/422indicate a config or contract problem that retrying will not fix — alert the operator instead. - A late or corrected re-run is fine: re-POST the full corrected report for the same
report_dateand 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: 1in every payload. - ✅ Keep
top_statskeys stable across days (weathertoday must beweathertomorrow) — user card preferences are keyed on them. - ✅ Use
httpsURLs inlink/urlfields; the frontend drops unsafe schemes. - ✅ Set
generated_atandtimezoneso 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_mdfirst 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
Authorizationheader.
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
Authorizationheader. - ❌ 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_versionbump 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-tiermorning_routinefeature flag (global + per-org). Ingest succeeding while the flag is off is expected.
Related files
- 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