All routes prefixed with /api/. When auth_required is enabled (see Settings), all routes except /api/auth/status, /api/auth/login, /api/health, and /api/sse require JWT auth via httpOnly cookie. When auth_required is disabled (default), all routes return the default admin user without authentication.
Return app settings (public). Also carries server capabilities that are not stored settings: max_agent_concurrency, default_schedule_timezone, install_id, and max_upload_size (bytes, from MAX_UPLOAD_SIZE in app/uploads.py — the browser reads this instead of hardcoding a cap; see Upload limits)
PUT
/api/settings
Update settings (admin only). Keys: auth_required, max_agent_concurrency (int, 1-10, live-updates semaphore), default_schedule_phase_mode (fanout | single — default mode pre-selected when creating new all-stores schedules), default_schedule_timezone (IANA name, validated via ZoneInfo; GET seeds with get_server_timezone() when unset), task_retention_days (int, 0-3650, default 30; controls the daily auto-cleanup job — 0 disables it. See docs/tasks.md § Task deletion + auto-cleanup), google_workspace_enabled
GET
/api/settings/google-workspace/status
Report gws binary presence, auth status, version, and whether the umbrella bundle is enabled/installed
POST
/api/settings/google-workspace/enable
Admin only. Validates prereqs (400 if gws missing or unauthenticated), runs gws generate-skills, installs the 19-skill umbrella at .claude/skills/gws/, sets google_workspace_enabled=true
POST
/api/settings/google-workspace/disable
Admin only. Idempotent. Removes .claude/skills/gws/ and clears the flag
List tasks (filter by store, archived, or parent task)
POST
/api/tasks
Create task (auto mode by default; plan_mode=true for plan mode; optional parent_task_id to create a sub-task — sub-tasks must have a store_id)
GET
/api/tasks/{task_id}
Get task by ID
DELETE
/api/tasks/{task_id}
Cascade-delete a task and its full subtree (children, grandchildren, …). Removes dependent rows (task_steps, task_messages, task_attachments, task_logs, screenshots), drops the on-disk workspace at ~/.vibe-seller/tasks/{task_id}/, and best-effort stops any running agent. Refuses with 409 if any task in the subtree is in designing / running — caller must Stop first. Idempotent: returns {ok: true} even when the task does not exist. Cleanup logic lives in app/task_delete.py and is shared with the auto-cleanup job.
Record task result summary (agent-only via MCP vibe_seller_set_task_result). Does NOT transition status; _auto_run_task handles that.
POST
/api/tasks/{task_id}/error
Record unrecoverable task error (agent-only via MCP vibe_seller_set_task_error). Does NOT transition status; _auto_run_task sees task.error during cleanup and transitions to FAILED.
POST
/api/tasks/{task_id}/ad-declaration
Declare what THIS PHASE of an ad task is for, before any browser work (agent-only via MCP vibe_seller_declare_ad_task). Body: {kind, scope} where kind ∈ audit/create/execute/investigate — there is no edit; "change these three bids" is a one-campaign audit. scope = {combos[], campaigns[], products[]}; absent combos means every marketplace the store sells on. Append-only: a second declaration is 409 unless a NEW user message has arrived since the last one, so only the person typing can open a phase. Two exceptions, both ratchets (ad_declaration.supersedes) — within a turn you may narrow at the same kind (recording campaign ids only enumeration could reveal), or correct investigate → audit at the same-or-narrower reach. The latter is legal because it adds obligation (coverage + a console); audit → investigate and every other pair stay 409, as does any widening. On accept, mirrored to AD_TASK.json in the task workspace for the stop gates (the row stays authoritative).
GET
/api/tasks/{task_id}/ad-declarations
Every declaration this task has made, oldest first. The frontend binds each result item to the declaration in force when it landed: kind == 'audit' opens the review console, and its scope bounds which campaigns are actionable.
GET
/api/tasks/{task_id}/schedule-state/{key}
Read a cross-run cursor persisted by a prior run of the same schedule (agent-only via MCP vibe_seller_get_schedule_state). Scope resolved server-side from task.schedule_id; non-scheduled tasks → 400. Response omits schedule_id so the MCP tool output cannot leak it to the agent. Keys must match [A-Za-z0-9_.-]{1,64}.
PUT
/api/tasks/{task_id}/schedule-state/{key}
Upsert a cross-run cursor (agent-only via MCP vibe_seller_set_schedule_state). Body: {value: string} (non-empty). Uses SQLite INSERT ON CONFLICT DO UPDATE so concurrent manual triggers can't race the PK insert. Same scoping / key rules as GET. Typed keys: email_watermark must be a unix epoch seconds integer string (regex ^[0-9]{1,15}$) — ISO timestamps are rejected because lex compare with microseconds / timezone variants is unsafe.
POST
/api/tasks/{task_id}/wake
Wake a waiting task (optional message)
GET
/api/tasks/{task_id}/messages
Get agent chat history
POST
/api/tasks/{task_id}/messages
Send chat message to agent; if task is in waiting status, also wakes the task (response includes woken: true)
GET
/api/tasks/{task_id}/files
List agent-generated files in task workspace (non-symlink, non-dot files in task root)
GET
/api/tasks/{task_id}/files/{filename}
Download a task output file (path traversal protected)
TaskResponse includes created_by_name: str | null — the creator's display name resolved from the users table (defaults to "admin" if not found).
Create a recurring schedule. Optional phase_mode (fanout | single) only honored when store_id is null; store-bound schedules always resolve to single. When omitted, server uses AppSettings.default_schedule_phase_mode, falling back to fanout. two_phase is system-only and rejected with 400. Optional fanout_serial (bool, default false) runs the fanout's stores one at a time (see All-Stores Scheduling); 400 when set on a store-bound or single schedule. When plan_mode=true, the response is returned with plan_status='planning' and a fresh is_plan_only Task is spawned; the schedule is not eligible to fire until the user approves the plan (see Plan-at-creation lifecycle).
GET
/api/schedules/{id}
Get schedule detail
PUT
/api/schedules/{id}
Update schedule. Mutable: title, description, schedule_type, schedule_time, schedule_day, interval_value, timezone, ai_profile_id, finalize_description, fanout_serial (400 on a store-bound or single schedule; omitted leaves it unchanged). plan_mode, phase_mode, and store_id are immutable (400 / 422 respectively). plan_version acts as an If-Match optimistic-lock token — stale values return 412. On a plan-mode schedule, a description change (normalized whitespace) aborts any in-flight planner and flips plan_status='stale'; call /replan to author a new plan.
DELETE
/api/schedules/{id}
Delete schedule
POST
/api/schedules/{id}/pause
Pause schedule
POST
/api/schedules/{id}/resume
Resume schedule. Returns 409 if plan_mode=true and plan_status != 'ready' (gate with the cron fire-gate so APScheduler doesn't fire a blocked schedule).
GET
/api/schedules/{id}/tasks
List child tasks for schedule
POST
/api/schedules/{id}/trigger
Manually trigger schedule. Returns 409 if plan_mode=true and plan_status != 'ready'. All-stores schedules route by phase_mode: fanout/two_phase → one task per active store; single → one no-store task. Store-bound schedules always create one store-scoped task. Before creating a per-store child task, any prior waiting task for the same (schedule, store) is cancel-forwarded (FAILED with error_category='superseded_by_next_fire') and a schedule_waiting_cancelled SSE event is emitted.
POST
/api/schedules/{id}/replan
Idempotent. Spawn a new is_plan_only Task to author the schedule's plan. If a non-terminal planning Task already exists, returns the current response without spawning a duplicate. Returns 400 for non-plan-mode schedules.
PUT accepts the mutable fields listed in the table above plus
optional plan_version (for the If-Match check). Unknown fields
return 422 (model_config = extra='forbid').
On create, if the request body omits timezone (or sends null), the router resolves it in order: AppSettings['default_schedule_timezone'] → get_server_timezone() (via tzlocal, UTC fallback). PUT accepts timezone and re-registers the APScheduler job with the new zone. Invalid IANA strings → 400.
Set a profile as the user's default. Response: {'ok', 'default_profile_id', 'schedules_synced'} — schedules_synced counts the caller's pinned schedules re-pinned to the new default (only when the caller's sync_profile_to_schedules pref is on; see subsystems.md § Schedule AI profile resolution)
POST
/api/profiles/validate
Probe a profile's endpoint config (no persistence)
GET
/api/profiles/presets
Provider presets + per-provider model options
POST /api/profiles/validate takes {"env": {...}} and makes one minimal Anthropic /v1/messages request against the config's own ANTHROPIC_BASE_URL (mirroring Claude Code's auth). It always returns HTTP 200 — the verdict is in the body: {"ok": bool, "code": str, "error": str, "reported_model": str | null}. The Settings UI hits it on save so an unreachable base URL, a wrong/expired key, a wrong protocol, or a retired model id is caught on the config page rather than on the next agent run. See backend.md § Profile Endpoint Validation.
Workspace grouped for the UI. One entry per store joining stores/<slug>/ knowledge files with store-data/<slug>/ run-data data_files/data_path (run-data sorted newest-first)
GET
/api/workspace/file
Read a text file ({path, content}); decodes utf-8 with GB18030 fallback; 400 with a pointer to /file/raw for binary
GET
/api/workspace/file/raw
Raw bytes with guessed content type. Content-Disposition: inline only for non-scriptable types (pdf, png/jpeg/gif/webp); everything else attachment (stored-XSS guard)
Workspace-level webhook configs for group chat bots. Not bound to any
store. Four platforms are supported: WeCom / WeChat Work (企业微信),
DingTalk (钉钉), Feishu / Lark (飞书) and Slack.
The platform is derived by the server from the webhook URL host —
it is never accepted from the client, so a stored bot can never
disagree with its own URL and a signing secret can never be posted to
a host the user did not choose. URLs must be https with an exact
host match (qyapi.weixin.qq.com, oapi.dingtalk.com,
open.feishu.cn / open.larksuite.com, hooks.slack.com /
hooks.slack-gov.com) plus the platform's path prefix; anything else
(cleartext, look-alike hosts like oapi.dingtalk.com.evil.com,
userinfo tricks like https://oapi.dingtalk.com@evil.com/...) → 400.
The single exception: a LOOPBACK host (strict ipaddress loopback or
localhost) is accepted — plain http included — only while the
server process runs with VIBE_ALLOW_LOOPBACK_WEBHOOKS=1, which the
offline e2e suite (CI and docker/e2e) uses to register a loopback
stub webhook; production leaves it unset. Send endpoints (/test,
/send, /send-file) re-derive the platform from the stored URL and
refuse with 400 when it no longer matches, so a row saved before this
validation cannot send to a non-platform host.
List responses mask webhook_url (the URL is the secret) and report
only has_secret — use the single-item GET to retrieve the full URL +
secret for the edit form. The keyword is returned in the list too: it
is not a secret (the UI shows it on the row), and it never reaches the
agent — keyword handling is entirely server-side.
Method
Path
Description
GET
/api/chat-bots/platforms
Registry metadata the UI renders from: {name, label, hosts, path_prefix, uses_secret, supports_file, max_message, limits, limit_unit, doc_url} per platform
GET
/api/chat-bots
List bots — {id, name, platform, webhook_url_masked, has_secret, can_send_file, created_at, updated_at}; the mask shows the host + last 4 chars of the secret-bearing part (e.g. https://hooks.slack.com/...****abcd)
GET
/api/chat-bots/{id}
Get one bot with the full webhook_url and secret (for edit)
POST
/api/chat-bots
Create {name, webhook_url, secret?, keyword?}. secret is only valid for DingTalk (加签) / Feishu (签名校验) — non-empty on WeCom/Slack → 400; keyword only for DingTalk (自定义关键词) / Feishu (关键词) — the SERVER appends it to any outgoing message that lacks it, so agents and the Test button work unchanged
PUT
/api/chat-bots/{id}
Update name, webhook_url, secret or keyword; null/blank required fields → 400; changing the URL re-derives platform (and drops a secret/keyword the new platform cannot use); "" clears a secret or keyword
DELETE
/api/chat-bots/{id}
Delete bot
POST
/api/chat-bots/{id}/test
Post a test message to the webhook; optional {content} (blank/whitespace → default, which starts with "Vibe Seller" — matters for DingTalk keyword security)
POST
/api/chat-bots/{id}/send
Post a real message: {content, msgtype?}. msgtype is text (default) or markdown; blank content / unknown msgtype → 400. Pre-send size check per platform (WeCom 4096 B, DingTalk 20000 B, Feishu 20000 B body, Slack 40000 chars text / 12000 markdown) rejects without a network call. Used by agents via the MCP bridge (see docs/workspace.md).
POST
/api/chat-bots/{id}/send-file
Upload a local file and post it as a file message: {path}. WeCom only — other platforms → 400 before path resolution. path is expanded (~) and must be absolute under an allowed root (/tmp, /private/tmp, or ~/.vibe-seller/downloads) — anything else → 400 (blocks exfiltrating the DB/secrets/~/.ssh). File must be 6 B–20 MB (WeCom limit). Returns {ok, message}. Used by agents via the MCP tool vibe_seller_send_chat_file.
| Platform | Text | Markdown | File | Signing | Keyword | Limits |
|---|---|---|---|---|---|
| WeCom 企业微信 | {"msgtype":"text","text":{"content":…}} | {"msgtype":"markdown","markdown":{"content":…}} (reduced subset) | Yes (upload_media → media_id → msgtype=file) | — | — | 4096 B / msg |
| DingTalk 钉钉 | {"msgtype":"text","text":{"content":…}} | {"msgtype":"markdown","markdown":{"title":…,"text":…}} — title derived from the first non-empty line | No | Optional 加签: HMAC-SHA256(key=secret, msg="<ts-ms>\n<secret>") → base64 → URL-encoded, sent as ×tamp=&sign=query params; combinable with a keyword | Optional 自定义关键词 — server appends it to any message that lacks it | 20000 B / msg, 20 msg/min |
| Feishu / Lark 飞书 | {"msg_type":"text","content":{"text":…}} | Interactive card (msg_type=interactive, card JSON 2.0, markdown element) | No | Optional 签名校验: HMAC-SHA256(key="<ts-s>\n<secret>", msg empty) → base64 (NOT URL-encoded), sent as top-level JSON bodytimestamp/sign | Optional 关键词 — server appends it to any message that lacks it | 20 KB body, 100 req/min, 5 req/s |
| Slack | {"text": …} with &<> escaped | Block Kit {"text": <summary>, "blocks":[{"type":"markdown","text":…}]} (falls back to a standard→mrkdwn converter if the block is rejected) | No | — (the URL is the secret) | — | 40000 chars text / 12000 markdown |
Backend senders live in app/notifiers/ — base.py holds the
PLATFORMS registry, detect_platform, shared error handling and the
size guard; one module per platform (wecom.py, dingtalk.py,
feishu.py, slack.py). Signing helpers are pure functions taking an
explicit timestamp (unit-tested against independent reference
implementations). Errors are logged server-side; the response message
never echoes the URL back to the client.
Start (or reuse) the store-less orchestrator web browser. Lazy-called by the bin/_web wrapper on first use; parallels POST /api/stores/{id}/browser/start. Accepts force for parity (no-op).
Return { platform: 'mac' \| 'windows' \| 'wsl' \| 'linux', version: str, commit: str \| null }. platform is the host OS; version is the package version from importlib.metadata ('0.0.0+dev' for editable / dev installs); commit is the short git SHA when the source tree is a git checkout (useful when version is the dev fallback). Frontend fetches this once on mount and uses it as the single source of truth for platform-conditional UI (Ziniao launcher download, install hints, etc.) and the about/footer build stamp.
GET
/api/system/update-check
Check PyPI for a release newer than the one running here. Dev/local checkouts (app.update_check.is_dev_version — setuptools_scm .devN+g<sha> builds or the +dev fallback) always get { dev: true }, since there's no PyPI release an arbitrary in-between commit corresponds to. Otherwise returns { dev: false, update_available: bool, current_version, latest_version?, platform?, upgrade_command?: str \| null, download_url?: str \| null, releases_page_url?, releases?: [{ version, name, body, url, published_at }] } — upgrade_command (vibe-seller upgrade) on macOS/Linux/WSL vs. download_url (GitHub releases page) on native Windows, matching the two install paths in the README. releases lists GitHub release notes for every version newer than the installed one (capped at 5), used for the frontend's "what's new" popup shown once per login (see useUpdateCheck / UpdateAvailableModal).
Every multipart upload route shares one contract, defined in
app/uploads.py: the same MIME allow-list (PNG / JPEG / GIF / WebP /
PDF) and one ceiling, MAX_UPLOAD_SIZE (100MB). It previously lived in
four places — create-task attachments at 10MB, chat staging at 15MB, the
vision reference image at 15MB, and a hardcoded copy in the browser — so
the same file was accepted by one box and refused by the next.
Routes covered: POST /api/attachments/{task_id},
POST /api/tasks/{task_id}/staged, and
POST /api/tasks/{task_id}/image/upload-reference.
Size is enforced while streaming, in two layers:
Layer
What it bounds
Response
UploadBodyLimitMiddleware (ASGI, app/uploads.py)
The request body itself, counted on the receive channel — so a chunked upload with no Content-Length cannot spool an unbounded temp file before the route runs
413
save_upload()
Memory while writing; aborts mid-write and deletes the partial file
413
The middleware applies only to multipart/form-data — it is not a
global request-body cap, so a large JSON PUT is unaffected. It is
registered before CORSMiddleware so CORS stays outermost and a 413
reaches a cross-origin caller as a readable status rather than an opaque
network error.
The frontend never states the limit itself: max_upload_size from
GET /api/settings drives both the hint text and the "too large"
message (frontend/src/uploadLimits.ts).
A store can be bound to the hosted ads service; vibe-seller holds no Amazon
credential. Nothing is configured: the service address is a constant in
app/ads_client.py, and the installation registers itself with the service
(POST /installations) the first time the stores are synced. The key it
gets is stored encrypted and never returned. Amazon stores only.
Method
Path
Description
GET
/api/ads/config
Whether this installation has registered (configured); never the key
POST
/api/ads/stores/sync
Register if needed, upload the stores and record which are authorized; write an authorized store's marketplaces back onto its platforms / countries / platform_countries (add only). Also how a deployment learns a consent finished; pulls the skill once a store is authorized
POST
/api/ads/stores/{id}/unbind
Revoke one store's authorization (admin only). Body {"purge": bool}: also delete its ad history, unless another installation still manages the same Amazon ads account (kept_shared). Removes the skill when no store is left authorized
GET
/api/ads/stores/{id}/auth-url
A consent link for the store (admin only)
POST
/api/ads/call
The agent's one door (MCP tool vibe_seller_ads_call); the key is added here, never seen by the agent
/api/ads/call:
Task scoping.task_id is required. A task bound to a store reaches
that store only; only a store-less task may name any authorized store.
Reserved paths./me, /auth-url, /assignments, /ads-binding,
/installations belong to a person, not an agent, and are refused.
File answers. A list the service answers as CSV is saved to
tasks/<id>/ads-data/; the agent gets the path, the row count and the
metadata from the X-Meta header.
List active plugins' frontend bundles for the dashboard loader: [{js_extension_path, requires_early_init}]. Returns [] in an OSS-only install (no frontend plugins). See backend.md § Plugin Framework.