VoiceDrop API ← Developers 中文

VoiceDrop API

The VoiceDrop backend is made up of three independent HTTP services, all running on Cloudflare under the same jianshuo.dev zone. A client typically only talks to the first two.

ServiceBase URLPurpose
Files APIhttps://jianshuo.dev/files/api/Accounts, recording/article files, sharing, WeChat Official Account, community. The vast majority of calls go here.
Agent Workerhttps://jianshuo.dev/agent/ (WS: wss://jianshuo.dev/agent/)Trigger mining, real-time status push, voice-driven editing.
Reco Workerhttps://jianshuo.dev/reco/Community feed ranking + engagement reporting. Can be unplugged at any time; if it's down, the main flow is unaffected.

Each service also has a *.jianshuo.workers.dev mirror (voicedrop-agent.jianshuo.workers.dev, voicedrop-reco.jianshuo.workers.dev) with identical behavior. Server-to-server calls within the same zone use the mirror to bypass Pages routing; clients should just use the jianshuo.dev/... URLs above.

The data flow in one sentence: upload .m4a → server-side mining (ASR + Claude) writes out articles/<stem>.json → client reads articles, shares them, publishes to WeChat Official Account / community.


1. Authentication

All requests use Authorization: Bearer <token> (the Files API also accepts a ?token=<token> query parameter). There are two tiers of credentials:

CredentialShapeHow to get itscope (data isolation)
anon tokenHigh-entropy string starting with anon_, ≥20 charsClient generates it once and keeps it (the same user always uses the same token)users/anon-<sha256(token)[:32]>/
session JWTh.p.sig (HS256)Exchange an Apple identityToken via POST auth/applescope inside the JWT

Key points:

Exchanging for a session JWT

POST /files/api/auth/apple
Content-Type: application/json

{ "identityToken": "<Apple identity token>", "fullName": "Zhang San", "email": "..." }

fullName / email are only provided by Apple on first authorization and are optional. If you include your current anon token (in the Authorization header), the Apple ID gets bound to that anon box and your data stays put.

Returns: { "session": "<JWT, valid for 365 days>", "scope": "users/<sub>/" }


2. General conventions


3. Files API

Everything requires a token except auth/apple, photo/*, and asset/wechat-covers/*.

Account

Method / pathDescriptionReturns
POST auth/appleSee above{session, scope}
GET whoamiThe scope resolved from the current token{scope:"users/<sub>/"}
GET token/articlesIssue a 24h read-only articles link{token, url, expires_in:86400}

Files (raw R2 read/write)

Method / pathDescriptionReturns
GET listList all objects within scope (pagination is aggregated, never truncated){files:[{name,size,uploaded}]}
PUT upload/<name>Upload (request body is the raw file bytes, Content-Type passed through). When name looks like VoiceDrop-*.m4a, mining is triggered automatically.{ok:true, name}
GET download/<name>Download raw bytes. HEAD on the same path fetches metadata only.File bytes
DELETE file/<name>Delete a single object{ok:true}

Recording filename convention: VoiceDrop-<ts>-<dur>-<weekday>-<period>[-<city>-<district>].m4a (pure ASCII). Only this prefix + suffix triggers automatic mining.

Articles (high-level CRUD, versioned)

<stem> = the recording filename with its extension removed. Prefer this set of endpoints over raw download/articles/<stem>.json — it flattens the internal version structure (versions[head]) into a top-level articles.

Method / pathDescriptionReturns
GET articlesList all articles{articles:[{stem,title,head,createdAt,updatedAt,count}]}
GET articles/<stem>Read one (current head version)Article document (see §6)
PUT articles/<stem>Write (automatically saved as a new version){ok, head}
DELETE articles/<stem>Delete the article + .srt + .empty sidecars{ok}
GET articles/<stem>/historyVersion history{head, versions:[...]}
PATCH articles/<stem>/headMove only the head pointer (undo / redo, no new version created), body {head:<n>}{ok, head}
PUT articles/<stem>/srtWrite the subtitle sidecar (request body is SRT text){ok}
PUT articles/<stem>/emptyMark as no speech, body {reason?}{ok}

Writing style (versioned)

The mining / editing prompts layer in the user's writing style. Storage is a versioned CLAUDE.json (schema-3, same versions[head] structure as articles). The writing style is the only versioned field; identity fields like the name live in the non-versioned doc.profile (renaming does not create a new style version). On read, it falls back to the "# My name" line in the legacy CLAUDE.md.

Method / pathDescriptionReturns
GET styleRead the current writing style + name{style, name, head, createdAt, updatedAt}; with only a legacy CLAUDE.md: {style, name, head:0, legacy:true}; neither exists → 404
PUT styleWrite. Body {style?, name?, source?}: pass style to save as a new version; pass name to update only profile.name (no new version); both may be passed together{ok, head}; both style and name empty → 400 empty_content
GET style/historyVersion history{head, versions:[{v,savedAt,source,style}]} (oldest-first, max 10 versions)
PATCH style/headMove only the head pointer (undo / redo, no new version created), body {head:<n>}{ok, head}; version not found → 404

Sharing & WeChat Official Account

Method / pathDescriptionReturns
GET share/articles/<stem>.jsonCreate / fetch the public short link for this article{url:"https://jianshuo.dev/voicedrop/<id>"}
POST wechat/articles/<stem>.jsonSynchronously publish the article as a WeChat Official Account draft (updates in place if already published). Requires WECHAT.json to be configured first.{ok,created,updated}; 409 wechat_not_configured; 502 {errcode,errmsg} passes through the real WeChat error

Community (shared cross-user space)

A post is a live pointer to an article (schema-2, no content copy) — edit the source article and the community reflects it immediately.

Method / pathDescriptionReturns
POST community/share/articles/<stem>.jsonShare / re-share one of your own articles. Body may include {replyTo:<shareId>} to mark it as a reply. Requires an Apple session, otherwise 403 needs_apple_signin.{ok, shareId}
GET community/listAll posts, newest first by first-shared time{posts:[{shareId,author,title,firstSharedAt,count,mine,replyTo?}]}
GET community/get/<shareId>Read one post (with live article content){shareId,author,title,articles:[{title,body}],owner,firstSharedAt,replyTo?}
GET community/replies/<shareId>Replies to a post, oldest first{posts:[...]}
GET community/shared/articles/<stem>.jsonWhether my article is already shared (drives the "Share / Update" button){shared:bool, shareId?}
POST community/unshare/<shareId>Take down your own post (owner only){ok}

To render photos in a community post: join the returned owner with the key from each [[photo:<relkey>]] marker in the body to get the full key, then fetch it via the public photo/<key> endpoint below.

Public assets (no token required)

Method / pathDescription
GET photo/<full R2 key>Fetch a session photo. Only accepts users/*/photos/*.(jpg|jpeg|png), CORS *, publicly cached. All photo display goes through this single endpoint.
GET asset/wechat-coversList WeChat Official Account cover image names → {covers:[...]}
GET asset/wechat-covers/<name>Fetch one cover image's bytes

4. Agent Worker

Base: https://jianshuo.dev/agent/. Auth is the same as the Files API (anon or session; editing requires a writable token).

Trigger mining

POST /agent/mine/trigger
Authorization: Bearer <any valid user token>

Wakes the server-side miner to process all pending recordings. Idempotent — already-processed ones are skipped. Returns 202 queued. The server already calls this automatically on .m4a upload, so manual calls are rarely needed.

wss://…/agent/edit?stem=<stem> — voice-driven editing

Once open it is a long-lived connection supporting multiple round trips; the server persists history per article, so context carries across turns.

Client → server (send an editing instruction):

{
  "type": "instruct",
  "text": "Make line 3 more concise, and delete photo 2",
  "images": [
    { "data": "<base64>", "key": "photos/<sessionTs>/<offset>-<rand>.jpg", "mediaType": "image/jpeg" }
  ]
}

(images is optional, for attaching new photos.)

Server → client (pushed in order for each instruction):

{ "type": "status",  "state": "working" }
{ "type": "updated", "article": { ... the full top-level articles document } }
{ "type": "reply",   "text": "Done", "ok": true }
{ "type": "error",   "message": "<reason>" }

Protocol rules (clients must follow):

wss://…/agent/status — real-time status push

Read-only subscription; the client sends no messages. Whenever a recording's status changes, the server pushes:

{ "type": "status_update", "stem": "VoiceDrop-xxx", "status": "asr" }

status values: asr (listening to the recording) · mining (mining articles) · ready (article ready) · empty (no speech). Use this to flip list-row badges in place, no polling needed.


5. Reco Worker

Base: https://jianshuo.dev/reco/. Auth: anon or session token (temp tokens are not accepted). Unpluggable — clients should use a 2s timeout of their own; if reco is down or times out, fall back to reverse-chronological order and the feed still works.

POST /reco/rank — feed ranking

Hand the posts from GET community/list to reco for ranking. Request:

{ "posts": [ { "shareId":"abc", "firstSharedAt":1700000000000, "replyCount":2, "author":"Zhang San" } ] }

Returns:

{ "order": ["<shareId>", "..."],
  "liked": ["<shareId>", "..."] }

order = the shareIds in ranked order; liked = the ones the current user has ❤️'d. Score = (1 + view·1 + finish·4 + like·3 + reply·5 + report·(-9)) / (ageHours+2)^1.5, then interleaved by author.

POST /reco/engage/<shareId> — engagement reporting (fire-and-forget)

{ "action": "view" }

actionview (opened the post) · finish (read to the end) · like (❤️) · report (report).


6. Data model

Article document (returned by GET articles/<stem>)

{
  "schema": 3,
  "id": "VoiceDrop-xxx",
  "sourceAudio": "VoiceDrop-xxx.m4a",
  "createdAt": 1700000000000,
  "updatedAt": 1700000000000,
  "transcript": "Raw dictation transcript…",
  "srt": "1\n00:00:00,000 --> ...",
  "status": "ready",
  "model": "claude-sonnet-4-6",
  "articles": [ { "title": "Title", "body": "Body markdown, with [[photo:<relkey>]] markers" } ]
}

R2 key cheat sheet (all under users/<sub>/)

keyContents
VoiceDrop-<ts>-….m4aRecording (uploading it triggers mining)
articles/<stem>.jsonArticle (exists = ready)
articles/<stem>.emptyNo-speech marker
articles/<stem>.srtSubtitle sidecar
photos/<sessionTs>/<offset>-<rand>.jpgSession photo (<offset> = whole seconds from the start of the recording)
CLAUDE.jsonWriting style (versioned schema-3, fed into the mining/editing prompts) — see the "Writing style" endpoints above
CLAUDE.mdStores only the user's name (legacy style fallback source; writes go to CLAUDE.json only)
WECHAT.jsonWeChat Official Account config {appid,secret,enabled,coverMediaIds}

7. End-to-end example (cURL)

TOKEN="anon_xxxxxxxxxxxxxxxxxxxx"
BASE="https://jianshuo.dev/files/api"

# 1) Upload a recording (triggers mining automatically)
curl -X PUT "$BASE/upload/VoiceDrop-20260627-093012-Sat-morning.m4a" \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: audio/m4a" \
  --data-binary @rec.m4a

# 2) (optional) Manually nudge the miner
curl -X POST "https://jianshuo.dev/agent/mine/trigger" \
  -H "Authorization: Bearer $TOKEN"

# 3) List articles, read one
curl "$BASE/articles" -H "Authorization: Bearer $TOKEN"
curl "$BASE/articles/VoiceDrop-20260627-093012-Sat-morning" -H "Authorization: Bearer $TOKEN"

# 4) Create a public share link
curl "$BASE/share/articles/VoiceDrop-20260627-093012-Sat-morning.json" \
  -H "Authorization: Bearer $TOKEN"

# 5) Publish as a WeChat Official Account draft (configure WECHAT.json first)
curl -X POST "$BASE/wechat/articles/VoiceDrop-20260627-093012-Sat-morning.json" \
  -H "Authorization: Bearer $TOKEN"

For live progress, subscribe to wss://jianshuo.dev/agent/status (with the same Authorization) and watch this recording's badge flip from asrminingready.