{"name":"Lighthouse Server","version":"0.3.0","description":"Self-hosted hub: (1) a generic file vault with upload/download/list and upload timestamps, (2) a Twitter/X archive browsable as an X-style timeline on web and mobile.","auth":{"type":"bearer","header":"Authorization: Bearer <API_TOKEN>","note":"Every /api/* data endpoint requires the token, reads included. Only /health, /llms.txt, /api/manifest and /api/public/* (published files) are public. The token is server-side config (API_TOKEN) and cannot be discovered via the API."},"human_entry_points":{"home":"/","timeline":"/timeline","articles":"/articles","files":"/files","agent_docs":"/agent","stories":"/stories"},"machine_entry_points":{"llms_txt":"/llms.txt","openapi":"/openapi.json","docs":"/docs","health":"/health"},"capabilities":[{"id":"files","title":"File vault","endpoints":[{"method":"POST","path":"/api/files/upload","auth":true,"summary":"Upload any file (multipart field 'file', optional 'description', 'public=true' to publish it for token-free download)"},{"method":"GET","path":"/api/files","auth":false,"summary":"List files with upload time (page, page_size, q, order)"},{"method":"GET","path":"/api/public/files","auth":false,"summary":"List published files only — no token needed"},{"method":"GET","path":"/api/files/{file_id}","auth":false,"summary":"Metadata for one file"},{"method":"GET","path":"/api/public/files/{file_id}/{filename}","auth":false,"summary":"Download a published file without a token"},{"method":"GET","path":"/api/files/{file_id}/{filename}","auth":true,"summary":"Download file (Bearer token, or a signed link; ?inline=1 renders in browser)"},{"method":"GET","path":"/api/files/{file_id}/share","auth":true,"summary":"Create a signed download URL valid for ttl seconds (?ttl=3600)"},{"method":"DELETE","path":"/api/files/{file_id}","auth":true,"summary":"Delete a file"}]},{"id":"tweets","title":"Tweet archive","endpoints":[{"method":"GET","path":"/api/tweets","auth":false,"summary":"List tweets (page, page_size, q, author, order=score|timeline|random). Default 'score' ranks by net votes, ties by newest"},{"method":"POST","path":"/api/tweets","auth":true,"summary":"Import one tweet from JSON (idempotent by tweet_id)"},{"method":"POST","path":"/api/tweets/batch","auth":true,"summary":"Import 1–500 tweets in one call"},{"method":"GET","path":"/api/tweets/feed","auth":false,"summary":"Thread-aggregated feed: groups tweets by thread.id before sorting/paging; items are {type:thread,...} or {type:tweet,...}; plus full threads[] summaries"},{"method":"POST","path":"/api/tweets/threads","auth":true,"summary":"Group tweets into a thread: {root_id, part_ids[1..19]}; folds votes/comments onto root"},{"method":"PATCH","path":"/api/tweets/threads/{thread_id}","auth":true,"summary":"Change root / reorder parts / add parts: {root_id?, part_ids?, add_part_ids?}"},{"method":"DELETE","path":"/api/tweets/threads/{thread_id}","auth":true,"summary":"Dissolve a thread, restoring each member's original votes/comments"},{"method":"DELETE","path":"/api/tweets/threads/{thread_id}/members/{tweet_id}","auth":true,"summary":"Detach one member: restores its votes; promotes a new root or auto-dissolves"},{"method":"GET","path":"/api/tweets/authors","auth":false,"summary":"Authors with tweet counts and latest date"},{"method":"GET","path":"/api/tweets/{tweet_id}","auth":false,"summary":"Single tweet with fresh media URLs"},{"method":"POST","path":"/api/tweets/{tweet_id}/media","auth":true,"summary":"Upload media files (multipart field 'media', repeatable) into the tweet's own media/ directory — the supported way to attach images/video"},{"method":"POST","path":"/api/tweets/{tweet_id}/vote","auth":true,"summary":"Vote a tweet up/down: {\"delta\": 1|-1}; each call adds one vote (no per-user dedup). Returns score/up/down"},{"method":"GET","path":"/api/tweets/{tweet_id}/comments","auth":false,"summary":"List comments, highest score first, ties by newest"},{"method":"POST","path":"/api/tweets/{tweet_id}/comments","auth":true,"summary":"Add a comment: {\"content\": \"…\"} (1–2000 chars)"},{"method":"POST","path":"/api/tweets/{tweet_id}/comments/{comment_id}/vote","auth":true,"summary":"Vote one comment up/down: {\"delta\": 1|-1}"}]},{"id":"articles","title":"Article archive (WeChat)","endpoints":[{"method":"GET","path":"/api/articles","auth":false,"summary":"List archived articles (page, page_size, q; tags= repeatable include labels, exclude_tags= repeatable excluded labels, tag_mode=any|all, tag= legacy one-label alias, untagged=1 for label-less items); order=timeline (default, newest first) or order=score (sum of per-image votes, ties by newest); items carry score and media_scores; plus a whole-collection tag cloud with counts and an untagged total"},{"method":"POST","path":"/api/articles","auth":true,"summary":"Import one article from JSON (idempotent by article_id)"},{"method":"POST","path":"/api/articles/batch","auth":true,"summary":"Import 1–500 articles in one call"},{"method":"POST","path":"/api/articles/{article_id}/media/vote","auth":true,"summary":"Vote one image up/down: {\"name\": \"<media_names[i]>\", \"delta\": 1|-1}; each call adds one vote (no per-user dedup). Totals are stored on that media[] entry by name; returns article_id, name, score, up, down, article_score (sum of visible images)"},{"method":"DELETE","path":"/api/articles/{article_id}","auth":true,"summary":"Soft-delete one article: sets meta.json hidden/hidden_at, keeps every file and the single-item GET readable, and omits it from list, tag cloud and totals. Idempotent; clearing the flag restores it"},{"method":"POST","path":"/api/articles/recombine","auth":true,"summary":"Recombine 1–100 selected hosted images: {\"items\": [{\"article_id\": \"…\", \"name\": \"…\"}], \"target_article_id\": \"…\" (optional)}. Without a target a new article is created, titled after the first image's source article, with source links kept only in meta.json. With a target the images are appended to that article. Images are copied into the target's own media/ with fresh thumbnails, then soft-hidden in the sources; a source with no visible media left is hidden as a whole. Originals are never deleted. Returns article_id, created, moved, hidden_articles"},{"method":"GET","path":"/api/articles/{article_id}","auth":false,"summary":"Single article with fresh media URLs"},{"method":"PUT","path":"/api/articles/{article_id}/tags","auth":true,"summary":"Replace one article's user labels: {\"tags\": [\"日记\"]} or a delimited string; an empty list clears them. A later re-import without tags keeps the labels"},{"method":"POST","path":"/api/articles/{article_id}/media","auth":true,"summary":"Upload media files (multipart field 'media', repeatable) into the article's own media/ directory — the supported way to attach images"},{"method":"POST","path":"/api/articles/{article_id}/media/hide","auth":true,"summary":"Soft-hide one media item: {\"name\": \"…\"}. Sets media[].hidden and hidden_at in meta.json; the file stays on disk and hidden items are omitted from every media_* array"}]},{"id":"stories","title":"Stories (personal notes)","endpoints":[{"method":"GET","path":"/api/stories","auth":false,"summary":"List notes (page, page_size, q; tags=, exclude_tags=, tag_mode=any|all, tag= legacy alias, untagged=1 for tag-less notes); newest edited first, plus a tag cloud with counts and an untagged total"},{"method":"POST","path":"/api/stories","auth":true,"summary":"Create a body-only note: content plus tags; title is optional, tags accept a list or a delimited string"},{"method":"GET","path":"/api/stories/{story_id}","auth":false,"summary":"Read one note"},{"method":"PUT","path":"/api/stories/{story_id}","auth":true,"summary":"Replace a note's content/tags (same body as create)"},{"method":"DELETE","path":"/api/stories/{story_id}","auth":true,"summary":"Delete a note"},{"method":"GET","path":"/api/pi/files","auth":false,"summary":"List note files in the read-only novels workspace (?path=subdir)"},{"method":"GET","path":"/api/pi/files/content","auth":false,"summary":"Read one note file (?path=...); hidden/credential files are refused"},{"method":"GET","path":"/api/pi/providers","auth":false,"summary":"List selectable Pi providers and their models (no credentials exposed); chat accepts optional provider/model to pick one"},{"method":"GET","path":"/api/pi/sessions","auth":false,"summary":"List saved Pi chat sessions"},{"method":"POST","path":"/api/pi/sessions","auth":true,"summary":"Create a Pi chat session"},{"method":"GET","path":"/api/pi/sessions/{session_id}","auth":false,"summary":"Read one session with its full message history"},{"method":"POST","path":"/api/pi/sessions/{session_id}/chat","auth":true,"summary":"Send a message; Pi runs read-only tools, reply is appended to the session"},{"method":"POST","path":"/api/pi/sessions/{session_id}/chat/stream","auth":true,"summary":"SSE stream of thinking/text deltas and tool status; the turn is saved on completion, or saved partially with interrupted=true on disconnect"},{"method":"DELETE","path":"/api/pi/sessions/{session_id}","auth":true,"summary":"Delete a saved session"}]},{"id":"download-jobs","title":"Archive by URL","endpoints":[{"method":"POST","path":"/api/download","auth":true,"summary":"Queue a URL for archival: {\"url\": \"…\"} — x.com/twitter.com or mp.weixin.qq.com"},{"method":"GET","path":"/api/jobs/{job_id}","auth":false,"summary":"Poll job status (pending→downloading→uploading→done|failed); kind=tweet|article, item_id=resolved id"}]}],"conventions":{"time":"All timestamps are UTC ISO-8601 (e.g. 2026-09-06T12:34:56.789012+00:00).","pagination":"page is 1-based; responses carry total/page/page_size/items.","media":"To store media, upload the files to POST /api/{articles,tweets}/{id}/media. Never put this server's own URLs (e.g. /api/files/...) in media_urls — that is rejected with 422 media_url_not_allowed and a how_to_fix guide. Genuine third-party links (e.g. pbs.twimg.com) are fine.","errors":"Errors are {\"detail\": str, \"code\": str}. Branch on code: unauthorized, invalid_order, file_not_found, invalid_token, upload_too_large, write_failed, delete_failed, storage_error, media_url_not_allowed, item_not_found, internal_error. Some errors add extra keys (e.g. 'rejected', 'how_to_fix')."}}