// SOFTWARE
Hub API
The stateless digest service that turns git activity into the Hub's journey feed.
Getting Started
Getting Started
Hub API is the stateless digest service behind the Hub's content pipeline. It never touches a filesystem, a database, or a git credential — every call carries everything it needs in the request body, and every response is fully reproducible from that same input alone.
Authentication
None. The service is only ever called from trusted callers on the home LAN — hub-products-journey.sh / hub-journey-rollup.sh / hub-article-draft.sh running on the Mac mini, and the PR review-resolution GitHub Actions workflow running on that same machine's self-hosted runner. hub-products-sync.sh (the hourly product-copy script) never calls this service — it's purely a file copy, no LLM involved. No API key, no auth header, no session.
How every call works
Every endpoint below follows the same discipline:
- The caller assembles a request carrying everything the model needs — voice/tone instructions, any relevant exclusion rules, and the raw material to synthesize from. Nothing is looked up from disk on this side; the caller supplies it all.
- This service calls a local Ollama model's
/api/generate— never/api/chat— withformatset to a JSON schema matching the endpoint's expected response shape. - The response is validated against that schema. A malformed or non-conforming result is retried, same request, up to 3 times (
POST /digest/article-draftmakes four sequential, single-purpose calls per request — Article Suggestion, Article Metadata, LinkedIn Post, Image Prompt — each with its own wider 6-attempt budget, since content-quality checks on its drafted output, not just schema conformance, can also trigger a retry). - If it still fails after exhausting that budget, the caller gets a clear error rather than a best-effort guess passed through silently — except
POST /digest/article-draft, which instead returns a normal response witharticleSuggestion: nulland its ownattemptFailures/validationExhausteddiagnostics, since a null result is already a valid outcome for that endpoint.
What this service does not do
It never produces or touches promotional copy or API reference documentation — that content (including this very page) is written directly by mar_claude_plugins agents in each product's own repo, not generated by this service. It also never applies a patch to disk itself — POST /digest/review-fix only ever returns a decision; the calling script is the one that writes to a file.
ENDPOINTS
OVERVIEW
Turns a product's git commits since the caller's last cursor into structured journey entries. This service groups the incoming commits by calendar date and, for each date, reads every commit message under it and summarizes them into one entry. The model judges which commits are journal-worthy using the assembled voice instructions and, if the repo defines one, its excludedContent rule (judged from commit message text alone — the caller sends no file paths), rather than the caller pre-filtering anything. Returns zero, one, or more entries — one per journal-worthy date, zero is valid when nothing since the cursor was journal-worthy.
REQUEST
{
"slug": "word-tracker",
"domain": "writing",
"platforms": ["ios"],
"voice": "...",
"excludedContent": null,
"gitCommits": [
{
"date": "2026-08-05T14:20:00Z",
"message": "feat: add offline sync queue\n\nRetries failed syncs when connectivity returns."
}
]
}| FIELD | TYPE | DESCRIPTION |
|---|---|---|
| slug | string | Which product this call is scoped to. |
| domain | string | The product's domain — informational only, doesn't affect routing. |
| platforms | array of string | The product's platforms — always ["api"] for the API product. |
| voice | string | Assembled tone/style instructions for journey-entry content. |
| excludedContent | string | null | Free-form description of git changes to treat as non-journal-worthy, from the repo's config.json. |
| gitCommits | array | Every commit since the caller's last cursor, in chronological order. The caller sends only date and message — no hash, file paths, or type hint. |
| date | string | Commit date, ISO 8601. |
| message | string | Full commit message, subject and body, verbatim. |
RESPONSE
{
"journeyEntries": [
{
"date": "2026-08-05",
"body": "Added a queue that retries failed syncs once connectivity returns."
}
]
}| FIELD | TYPE | DESCRIPTION |
|---|---|---|
| journeyEntries | array | Zero, one, or more generated entries — at most one per calendar date present in gitCommits, since dates are grouped before generation. |
| date | string | Calendar date (YYYY-MM-DD) this entry summarizes. |
| body | string | Entry body, 1 to 3 sentences, summarizing every commit on that date. |
ERRORS
| CODE | DESCRIPTION |
|---|---|
| invalid_request | The request body is missing a required field or fails schema validation. |
| generation_failed | Ollama's response still failed schema validation after 3 retries. |
OVERVIEW
Synthesizes a set of already-digested per-product journey entries into one top-level rollup for a week or month. Reasons only over each entry's slug, date, and body (already 1–3 sentences, so it's reused directly rather than re-summarized), never raw git data, which keeps the prompt small. Returns only the rollup entry — article suggestions come from a separate, independent endpoint.
REQUEST
{
"periodStart": "2026-07-28",
"periodEnd": "2026-08-03",
"voice": "...",
"productJourneyEntries": [
{
"slug": "word-tracker",
"date": "2026-08-01",
"body": "Added a queue that retries failed syncs once connectivity returns."
}
]
}| FIELD | TYPE | DESCRIPTION |
|---|---|---|
| periodStart | string | Rollup period start date, YYYY-MM-DD. |
| periodEnd | string | Rollup period end date, YYYY-MM-DD. |
| voice | string | Assembled tone/style instructions for the rollup entry. |
| productJourneyEntries | array | Per-product journey entries produced since the last rollup. |
| slug | string | Which product this entry belongs to. |
| date | string | Entry date. |
| body | string | The entry's own body, 1 to 3 sentences. |
RESPONSE
{
"rollupEntry": {
"timePeriod": "week",
"startPeriodDate": "2026-07-28",
"endPeriodDate": "2026-08-03",
"body": "A quiet but steady week — offline sync shipped for Word Tracker and the Hub digest pipeline took its first real shape.",
"relatedProducts": ["word-tracker"]
}
}| FIELD | TYPE | DESCRIPTION |
|---|---|---|
| rollupEntry | object | The top-level synthesis for this period — the only thing this endpoint returns. |
| timePeriod | string | "week" or "month". |
| startPeriodDate | string | Period start date. |
| endPeriodDate | string | Period end date. |
| body | string | Rollup body, 3 to 5 sentences. |
| relatedProducts | array of string | Product slugs referenced, for linking down to their journey pages. |
ERRORS
| CODE | DESCRIPTION |
|---|---|
| invalid_request | The request body is missing a required field or fails schema validation. |
| generation_failed | Ollama's response still failed schema validation after 3 retries. |
OVERVIEW
Proposes at most one full candidate blog article draft per request, assembled from four sequential, single-purpose Ollama calls (Article Suggestion, Article Metadata, LinkedIn Post, Image Prompt) over the same kind of per-product journey material the rollup endpoint uses — its result never depends on what the rollup entry turned out to say. The suggestion, when present, is a complete, reviewable draft body, not a title-only stub.
REQUEST
{
"voice": "...",
"productJourneyEntries": [
{
"slug": "word-tracker",
"date": "2026-08-01",
"body": "Added a queue that retries failed syncs once connectivity returns."
}
]
}| FIELD | TYPE | DESCRIPTION |
|---|---|---|
| voice | string | hub/voice/base.md content only — this is the one digest endpoint with no per-content-type overlay. |
| productJourneyEntries | array | Per-product journey entries for this period — same shape as /digest/journey-rollup's request. |
| slug | string | Which product this entry belongs to. |
| date | string | Entry date. |
| body | string | The entry's own body, 1 to 3 sentences. |
RESPONSE
{
"articleSuggestion": {
"title": "What offline-first sync actually costs you",
"slug": "offline-first-sync-cost",
"angle": "A technical retrospective on the tradeoffs of building offline-first sync.",
"body": "...markdown...",
"tagline": "Offline sync sounds simple. It never is.",
"categories": ["Software"],
"imagePrompt": "...",
"linkedinPost": "...",
"sourceEntries": ["word-tracker"]
},
"attemptFailures": [],
"validationExhausted": false
}| FIELD | TYPE | DESCRIPTION |
|---|---|---|
| articleSuggestion | object or null | At most one full candidate article draft, not a stub — null when nothing this period was judged worth drafting, or when a step's retry budget was exhausted (validationExhausted distinguishes the two). |
| title | string | Article title. |
| slug | string | Kebab-case, becomes the folder name and frontmatter slug — written explicitly by the model, never derived from title. |
| angle | string | The core idea, for quick context in the PR description. |
| body | string | Complete drafted article body, full Markdown. |
| tagline | string | One sentence, for the /blog card. |
| categories | array of string | Blog categories this article belongs to, from: Software, AI, Self-hosting, Automation, Aviation. |
| imagePrompt | string | Full image-generation prompt text, written to image-prompt.md. |
| linkedinPost | string | Full LinkedIn promo post, written to post.md. |
| sourceEntries | array of string | Which products' entries inspired this draft — plain product slugs, not slug/date pairs. |
| attemptFailures | array of string | One entry per failed generation attempt across all four internal steps (up to 6 per step), in order — a short reason each, prefixed with which step it came from (e.g. "Article Suggestion: corrupted response", "Article Metadata: leaked reasoning", "LinkedIn Post: article too short", "Image Prompt: leaked reasoning"). Empty when every step's first attempt was accepted. |
| validationExhausted | boolean | true exactly when articleSuggestion is null — some step's retry budget was exhausted before ever reaching a publishable result. Ollama always attempts to write an article from the given entries; there is no separate 'not worth drafting' outcome. |
ERRORS
| CODE | DESCRIPTION |
|---|---|
| invalid_request | The request body is missing a required field or fails schema validation. |
OVERVIEW
Applies a single PR review comment to one piece of generated content as a structured patch, never as an open-ended edit. The model decides only the shape of the fix — replace a range, replace the whole file, or make no change — while the calling script decides whether that action is allowed and applies it deterministically. Scoped to one comment per call, and never touches promo copy or API reference content.
REQUEST
{
"threadId": "PRRT_kwDO...",
"filePath": "src/content/journey/products/word-tracker/2026-08-01.md",
"fileContent": "---\ndate: \"2026-08-01\"\n---\n...",
"anchorExcerpt": "...the exact quoted lines the comment is attached to...",
"commentBody": "This makes it sound more finished than it was — say it's still flaky.",
"contentType": "journey",
"voice": "...",
"structuralConstraints": ["frontmatter field (date) must not change"]
}| FIELD | TYPE | DESCRIPTION |
|---|---|---|
| threadId | string | GitHub review-thread ID, echoed back for matching. |
| filePath | string | Path to the target file within web_app/, relative. |
| fileContent | string | Full current content of the target file. |
| anchorExcerpt | string | The exact lines the reviewer's comment is anchored to. |
| commentBody | string | The reviewer's feedback, verbatim. |
| contentType | string | "journey", "rollup", or "blog" — selects the voice overlay and structural constraints. |
| voice | string | Assembled tone/style instructions matching contentType. |
| structuralConstraints | array of string | Content-type-specific guardrails the model must not violate. |
RESPONSE
{
"threadId": "PRRT_kwDO...",
"action": "replace_range",
"newContent": "...revised excerpt...",
"rationale": "Softened the claim per reviewer feedback; left the rest unchanged."
}| FIELD | TYPE | DESCRIPTION |
|---|---|---|
| threadId | string | Echoed back so the caller can match the response to its thread. |
| action | string | "replace_range", "replace_file", or "no_change". |
| newContent | string | null | Required unless action is "no_change" — the replacement content. |
| rationale | string | Always present — goes into the commit message (applied) or the bot reply (no_change) either way. |
ERRORS
| CODE | DESCRIPTION |
|---|---|
| invalid_request | The request body is missing a required field or fails schema validation. |
| generation_failed | Ollama's response still failed schema validation after 3 retries. |