Subsystem Details¶
Deep-dive architecture for cross-cutting subsystems. See also DESIGN.md.
All-Stores Scheduling¶
Cross-store scheduling. Three recurring task patterns:
| Pattern | Store binding | phase_mode |
Tab | Example |
|---|---|---|---|---|
| Store-specific | One store (store_id set) |
forced single |
Scheduled | "Check Store A listings daily" |
| All-stores (fan-out) | All stores (store_id=NULL) |
fanout |
Scheduled | "Check inactive listings for every store" |
| All-stores (single) | All stores (store_id=NULL) |
single |
Scheduled | "Poll shared IMAP mailbox every 5 min" |
- Fan-out / single / two-phase runners:
app/scheduler/fanout.py— theSchedule.phase_modecolumn controls orchestration for all-stores schedules. The choice is made at APScheduler registration time incron.add_schedule_job(): phase_mode='fanout'(back-compat default):_run_fanout_jobcreates one per-store task fired in parallel, no prerequisite.phase_mode='single':_run_single_jobcreates exactly onestore_id=Nonetask per tick. Intended for shared work (IMAP sweeps, account health checks, housekeeping) where per-store fanout is wasteful or semantically wrong.phase_mode='two_phase'(system-only, currently only the catalog-sync schedule; never client-selectable): Phase 1 creates a single no-store prerequisite task (e.g. global L2 catalog) and awaits completion, then Phase 2 fans out one per-store task (e.g. L3 catalogs).- Finalize / parent-reduce step (the join, mirror of two_phase's prerequisite): an all-stores fanout schedule may set
Schedule.finalize_description. When set,app/scheduler/finalize_reaper.py(a periodic single-writer reaper, mirrorsplan_reaper/stall_reaper) fires one no-store task (Task.is_finalize=True) after every per-store child of a batch is terminal — handing it abatch_results.json(per child: status, result, error, store_slug, task_dir). The framework decides nothing past fire-once-after-all-terminal; the prompt (finalize_description) drives retry/summarize/publish. It's agent-authored at plan time via thevibe_seller_register_finalizeMCP tool and shown in the plan-review panel.finalize_descriptionis only valid for all-stores fanout schedules (create/update + the MCP endpoint reject it otherwise).is_finalize(not "store_id IS NULL") disambiguates the finalize task from a two_phase L2 prereq task, which is also no-store. - One store at a time (
Schedule.fanout_serial, opt-in, all-stores fanout only): a batch's per-store children run one after another, in creation (store) order, instead of all at once. A cron time cannot stagger a fanout, and every store shares one anti-detect client, so two stores driving the same portal in the same instant starve each other at the launch lock.TaskQueueScheduler._serial_sibling_aheadholds a child while any sibling is RUNNING/DESIGNING (or dispatched and not yet RUNNING) — earlier or later, so a child that comes back after a wake or a retry waits for the one running — or while an earlier sibling is still PENDING/QUEUED/PLANNED. FAILED and WAITING release the line (a child asking the user a question may wait a month and holds no agent). The gate is derived from the batch's rows on every tick, so a restart needs no recovery. Create/update reject it with 400 on store-bound and single-phase schedules, where it would silently do nothing; the create and edit modals show the checkbox only on an all-stores fanout. - Mode resolution on create: store-bound → forced
single; else client-supplied value (rejectstwo_phase); elseAppSettings.default_schedule_phase_mode; elsefanout.phase_modeandstore_idare immutable after creation —ScheduleUpdateusesextra='forbid'so mutation attempts return 422. - No-store tasks:
store_id=None. They go throughTaskQueueSchedulerbut bypass the per-store browser config, session tracking, and store CDP proxy. L2 catalog tasks are pure file operations; general orchestrator tasks additionally get the store-lesswebbrowser wrapper (bin/_web, lazy-started) for neutral public web work — see docs/browser.md. - Task grouping:
Task.batch_idUUID groups tasks from same fan-out trigger - Plan authoring (plan-mode schedules): Plan is authored at schedule creation via a dedicated
is_plan_onlyTask, stored onSchedule.plan, and gated byplan_status. Every fire copies the frozen plan into its child task and skips the design phase. See Plan-at-creation lifecycle below. - No new task status: fan-out tasks use standard lifecycle (pending → queued → designing → planned → running → completed/failed)
- Session reuse: existing per-store task queue serializes fan-out tasks; browser session persists between queued tasks
- Frontend: all-stores schedules shown with "All Stores" badge; child tasks grouped by store (collapsed by default)
- Task archiving: terminal tasks (completed/failed) >7 days hidden by default, toggle to show
- Timezone resolution: every schedule stores an IANA
timezonecolumn. When a client omitstimezoneon create, the router resolves it in order: (1) explicitbody.timezone, (2)AppSettings['default_schedule_timezone'], (3)get_server_timezone()inapp/scheduler/cron.py(wrapstzlocal.get_localzone_name(), UTC on failure).build_trigger()feeds the stored string intoZoneInfoso APScheduler fires in that zone. PUT/api/schedules/:idre-registers the APScheduler job so timezone edits take effect immediately.
Schedule AI profile resolution¶
Every schedule carries Schedule.ai_profile_id with two meanings: 'default'/NULL = inherit (follow the owner's current User.default_profile_id), any concrete value = explicit pin (always that profile). resolve_schedule_profile() (app/ai/profiles.py) runs live at every fire — cron (scheduler/cron.py), all-stores fan-out (scheduler/fanout.py), and finalize (scheduler/finalize_reaper.py) — and the resolved id is stamped onto the fired Task.ai_profile_id as the run's receipt. Switching the default profile therefore moves every unpinned schedule on its next fire with no schedule edits; pinned schedules are immune by design.
Opt-in sync on default change: the per-user pref User.sync_profile_to_schedules (default off, toggle in Settings → AI profiles) makes PATCH /api/profiles/{id}/set-default additionally re-pin the caller's own schedules that hold a concrete ai_profile_id to the new default (UPDATE ... WHERE created_by = me AND ai_profile_id NOT IN (NULL, 'default')). Inherit rows and other users' schedules are untouched; the endpoint response reports how many rows moved in schedules_synced.
Plan-at-creation lifecycle¶
Plan-mode schedules (Schedule.plan_mode=True) author their plan
once at creation, not on every fire. The plan lives on
Schedule.plan and is governed by a lifecycle column plan_status
(PlanStatus enum in app/plan_states.py):
(no plan_mode)
create ─────────────────── ready
│
│ (plan_mode=True)
▼
planning ──(user /replan)──┐
│ ▲ │
│ ok │ fail │
▼ │ │
ready ◄───┴── failed │
│ │
│ (prompt edited) │
▼ │
stale ───(user /replan)────┘
| Status | Meaning | Fire-gate |
|---|---|---|
none |
No plan authored (plan_mode=False, or never planned) | pass (plan_mode=False only) |
planning |
An is_plan_only Task is authoring the plan |
block |
ready |
Plan committed; schedule eligible to fire | pass |
stale |
Prompt was edited; stored plan is no longer valid | block |
failed |
Planner task failed or timed out | block |
Fire-gate¶
Enforced inside both app/scheduler/cron.py:_run_task_job and
app/scheduler/fanout.py:run_fanout_job / run_single_job:
fire eligible iff
is_system == True
OR plan_mode == False
OR plan_status == 'ready'
Non-ready plan-mode schedules are skipped with a schedule_skipped
SSE event. Manual POST /api/schedules/{id}/trigger and /resume
mirror the gate with a 409.
Cancel-forward on next fire¶
If a prior run for (schedule, store) is still in waiting when the
next fire lands, the scheduler cancels the waiter (FAILED with
error_category='superseded_by_next_fire') and emits
schedule_waiting_cancelled before creating the new run. Silently
skipping would stall a daily schedule for up to 30 days on one
forgotten user question.
Plan-only Task flow¶
A plan-only Task (Task.is_plan_only=True) is a one-shot planner
that terminates at completed without executing. auto_approve_plan
is forced to False for these tasks (narrowed gate in
app/task_runner_auto.py), so the agent pauses at ExitPlanMode
and waits for human review via the standard review-plan UI.
On approval, _commit_plan_only_approval in
app/ai/claude_backend_hooks.py runs inside a single DB transaction:
- Task DESIGNING/PLANNED → COMPLETED.
Schedule.plan = <plan_text>,plan_status='ready',plan_version += 1,plan_error=None,current_planning_task_id=None.- Sends a
denyControlResponse so the agent halts instead of entering bypass mode. - Emits
task_update+schedule_plan_readyonly after commit.
Invalidation on prompt edit¶
PUT /api/schedules/{id}:
plan_modeis immutable after creation — attempts return 400.plan_versionacts as anIf-Matchoptimistic-lock token — stale values return 412. Bumped on every successful plan commit.descriptionchanges are normalized (strip + collapse whitespace) before comparison so cosmetic edits don't spuriously invalidate.- Prompt change → abort any in-flight planner (stop session, FAIL
the Task with
error_category='superseded_by_edit') and setplan_status='stale'. The old plan text is kept so the UI can show a diff until the user re-plans. - Cron/timezone/store changes do not invalidate.
/replan (idempotent)¶
POST /api/schedules/{id}/replan spawns a new plan-only Task. If a
non-terminal planning Task already exists for the schedule, the
endpoint returns the current response without spawning a duplicate.
Non-plan-mode schedules return 400.
Stuck-planning reaper¶
app/scheduler/plan_reaper.py:reap_stuck_planning_tasks runs every
5 minutes (registered in cron.py:_register_plan_reaper). For each
Schedule with plan_status='planning' whose planner Task is neither
WAITING nor younger than 30 min, it:
- Calls
agent_manager.stop(task.id). - Transitions the Task to FAILED with
error_category='planning_timeout'. - Sets
plan_status='failed',plan_error='Planning timed out', and clearscurrent_planning_task_id. - After commit, emits
schedule_plan_timeout.
WAITING is explicitly preserved — it is legitimate user-input wait,
handled by waiting.py's 30-day timeout, not this reaper.
Key file map¶
| Component | File |
|---|---|
| Plan state enum | app/plan_states.py |
| Schedule model (plan columns) | app/models/schedule.py |
Task model (is_plan_only, plan_version) |
app/models/task.py |
| Router endpoints (create/update/replan/trigger/resume) | app/routers/schedules.py |
| Fire-gate + cancel-forward | app/scheduler/cron.py, app/scheduler/fanout.py |
| Approval hook + plan commit | app/ai/claude_backend_hooks.py:_commit_plan_only_approval |
| Reaper | app/scheduler/plan_reaper.py |
auto_approve_plan narrowing |
app/task_runner_auto.py |
| Prompt block for plan-only tasks | app/task_runner.py:build_system_extra |
Skill Prerequisite Enforcement¶
Skills can declare requires: [<prereq-skill-name>] in their YAML frontmatter. The PreToolUse hook enforces the order: a Skill(<dependent>) call is denied until <prereq> has been loaded in the same session — same mechanism shape as Claude Code's built-in Read-before-Write rule.
Why¶
Several skills are layered: e.g. noon-ads needs noon-shared's login flow and URL map before any ad work makes sense. A prose > **PREREQUISITE:** Read ../noon-shared/SKILL.md line in the dependent's SKILL.md is the spec, but non-Claude models (and Claude under load) sometimes skip it and run with a partial context. The hook turns that prose into a mechanism.
Each e-commerce platform ships as one such family: amazon-shared + amazon-{ads,listing}, noon-shared + noon-{ads,…}, and — for Taobao/Tmall — qianniu-shared + qianniu-{listing,ads}. Qianniu differs from the Amazon/Noon (Ziniao) families in its browser layer: it runs on the chrome backend with a human-in-the-loop login (QR scan or slider-captcha + SMS — no auto-captcha), a wrong-account hard gate, and a promo/公告 popup-close loop; its exports are GB18030-encoded CSV. All writes (Excel 批量导入, ad-plan changes) are surfaced for human review, never auto-executed.
Frontmatter format¶
---
name: noon-ads
description: "Noon Ad Manager — campaigns, tuning audits, ..."
requires: [noon-shared]
---
Only the inline-list form is parsed (parse_skill_requires in app/ai/claude_backend_utils.py). Multiple prereqs are allowed: requires: [a, b]. Skills with no requires: (and unknown skills not shipped by us) pass through unchanged.
Hook flow¶
Skill(noon-ads) tool_use
↓
Claude Code → control_request hook_callback (PreToolUse, tool_name="Skill")
↓
_handle_hook_callback (claude_backend_hooks.py)
↓
_check_skill_prereqs("noon-ads"):
• find_skill_md(workspace, "noon-ads") → task_dir/.claude/skills/noon-ads/SKILL.md
• parse_skill_requires() → ["noon-shared"]
• missing = [r for r in requires if r not in self._loaded_skills]
↓
missing? → control_response {permissionDecision: "deny",
permissionDecisionReason: "Skill 'noon-ads' requires 'noon-shared'
to be loaded first. Call ..."}
not missing? → allow, then self._loaded_skills.add("noon-ads")
The deny lands back at Claude Code as an is_error: true tool_result, which the agent reads and reacts to in the same turn — typically by calling Skill(noon-shared) next, then retrying Skill(noon-ads).
One load path: the Skill tool¶
A skill is loaded — added to _loaded_skills, durably bound via record_skill_load, and its gates: armed — only by a Skill(<name>) call, the same single path Claude Code itself uses (addInvokedSkill / registerSkillHooks). Reading a SKILL.md as a file (Read, cat, sed -n, …) loads nothing; the hook allows it and, if the skill isn't loaded, attaches additionalContext saying that a lookup can carry on but following the procedure needs Skill(<name>) (skill_gate_utils.unloaded_skill_read_hint). Reads used to bind too, and that guess bound the wrong gates: an ad audit sed-ed 22 lines of review-collect for a metadata shape, review-collect's gate refused the ad report, and the run went collecting product reviews.
Per-session state¶
self._loaded_skills: set[str] lives on AgentSession. It resets when the session resets (e.g. a fresh restart of a failed task) — which is correct, because the restarted agent's context window doesn't have the prior skill body. The hook then re-enforces the order against the fresh session.
Key files¶
| Concern | File |
|---|---|
Frontmatter requires: parser |
app/ai/claude_backend_utils.py:parse_skill_requires |
| SKILL.md path resolver (task workspace → global fallback) | app/ai/claude_backend_utils.py:find_skill_md |
| Hook deny path | app/ai/claude_backend_hooks.py:_check_skill_prereqs |
| Per-session loaded set | app/ai/claude_backend.py:AgentSession._loaded_skills |
| E2E proof of full deny → retry → load cycle | tests/e2e/test_skill_prereq_hook.py |
Email System¶
Persistent per-account email storage with background sync and SMTP send capability.
- EmailAccount model: Stores IMAP + SMTP credentials with Fernet-encrypted passwords (key from
JWT_SECRETvia SHA-256). SMTP columns:smtp_host,smtp_port,smtp_use_tls - StoreEmailLink: Many-to-many junction linking email accounts to stores
- Per-account SQLite DBs: Each email account gets its own SQLite database at
~/.vibe-seller/data/email_dbs/email_{account_id}.db(managed byapp/email/db.py). Stores messages with full headers, body, and metadata - Background sync:
app/scheduler/email_sync.pysyncs all accounts every 5 minutes (concurrent with semaphore to limit load). Replaces the old channel-based polling - Auto-discovery:
app/channels/email_discovery.pyprovides both IMAP and SMTP auto-discovery for top providers (163, Gmail, QQ, Outlook, Yahoo), with heuristic fallback - SMTP send:
app/email/sender.py— send emails via SMTP through any configured account. Exposed viaPOST /api/email-accounts/{id}/send - Agent access: Agents query emails via
sqlite3CLI against the local DB files. Store context (_build_store_context) injects DB paths and schema info. MCP toolsvibe_seller_email_info(DB paths + schema) andvibe_seller_send_email(send via SMTP) replace the oldvibe_seller_poll_emails - Waiting task checker: Queries the local SQLite DB for keyword matches instead of live IMAP polling
Chrome Persistent Profiles & Bookmarks¶
- Persistent profiles: Managed by the browser-use (0.13
browser_harness) daemon per session. Chrome stores use theBU_NAME={slug}env var (0.13; was--session {slug}) for profile isolation. Cookies, localStorage, and login sessions survive across tasks. - Ziniao aux sessions: Ziniao stores also get an auxiliary Chrome session (
{slug}-aux) for non-seller-center URLs (Google, logistics sites, etc.). The aux session starts lazily on first use — zero overhead if unused. - Proxy config: Only relevant for Chrome stores in UI.
- Bookmarks:
read_bookmarks(slug)readsDefault/BookmarksJSON from the profile dir. Auto-injected into agent context when knowledge files are sparse.
Browser & Task Concurrency¶
Concurrent per-store execution: Multiple tasks for the same store can run concurrently (up to the proxy's max_clients connection limit, default 16 per CDPMuxProxy instance) when they share the same platform/country. The CDPMuxProxy provides per-task isolation within a single browser session via request ID rewriting and session-based event routing. Tasks that share the same platform but target different countries are queued since Ziniao needs to switch country; all other combinations run concurrently.
Dispatch: All store-task launch paths (create, retry, continue, execute-plan) route through _schedule_or_run() which submits to TaskQueueScheduler when it's running. No-store tasks launch directly. The scheduler uses can_schedule() to gate by platform/country compatibility (RUN, RUN_IN_NEW_TAB, or QUEUE).
Per-task daemon sessions: Each task gets its own browser-use daemon session, named via the BU_NAME env var (0.13): {slug}-{VIBE_TASK_ID[:8]}. CDPMuxProxy is the primary isolation mechanism — each task connects with a unique client ID (/client-{task_id}, injected as BU_CDP_WS) and receives isolated CDP sessions via request ID rewriting and session-based event routing. The wrapper validates the session name via regex (^{slug}(-aux|(-{8hex})?(-w{N})?)$).
Parallel workers within one task: a task's subagents share its process and therefore its VIBE_TASK_ID, so two of them driving the browser at once would land on one daemon and one tab. browser-use --worker N gives each concurrent driver its own daemon ({slug}-{task[:8]}-wN) and its own mux client (client-{task}-wN) on the same logged-in browser. The bound is VIBE_BROWSER_WORKER_SLOTS (default 3, 0 disables); the parent agent assigns the numbers. See browser.md § Parallel workers.
These bounds multiply. One store's proxy serves
MAX_AGENT_CONCURRENCY × (1 + VIBE_BROWSER_WORKER_SLOTS) + 1 auxclients — 9 at the defaults, against a cap of 16.max_agent_concurrencyis user-settable up to 10 from Settings, and nothing recomputes the cap, so a high setting combined with worker slots can exhaust it on a single store and get clients refused with close code 4029.
Ziniao guard (account conflict): Only one Ziniao account can be active per machine (one Ziniao process). Multiple profiles (different browserOauth) on the same account work fine — each gets a unique debuggingPort and CDP proxy. But if store A uses Ziniao account #1 and store B uses account #2, store B's task will fail with a clear error: "Store(s) [Store A] are using a different Ziniao account. Stop the browser session first." Chrome stores have no such account restriction (but still use CDPMuxProxy for shared browser and cookie persistence).
Ziniao dual-browser: Ziniao stores get dual-session support in their wrapper — a main session ({slug}, routed via CDP proxy to Ziniao) and an aux session ({slug}-aux, Chrome for non-seller-center URLs). The agent uses AI judgment to route: seller center → Ziniao session, everything else → Chrome aux session. Routing rules defined in app/prompts/dual_browser.md. Users can override via stores/{slug}/browser-routing.md.
System Scheduler Jobs¶
start_scheduler() in app/scheduler/cron.py registers a fixed set of system-only APScheduler jobs (none of these correspond to a row in the schedules table, so they don't appear in the UI):
| Job id | Trigger | Source | Purpose |
|---|---|---|---|
waiting_checker |
every 15 min | app/scheduler/waiting.py |
Check WAITING tasks for email/condition triggers; time out stale waits |
email_sync |
every 5 min | app/scheduler/email_sync.py |
Sync configured email accounts into per-account SQLite DBs |
plan_reaper |
every 5 min | app/scheduler/plan_reaper.py |
Fail schedules stuck in plan_status='planning' |
stall_reaper |
every 1 min | app/scheduler/stall_reaper.py |
Terminalize RUNNING tasks whose agent can no longer progress |
task_cleanup |
daily 03:30 server-tz | app/scheduler/task_cleanup.py |
Auto-delete terminal leaf tasks past AppSettings.task_retention_days (see docs/tasks.md § Task deletion + auto-cleanup) |
stall_reaper's authority split — who may declare "this request is dead": while the agent process is alive and input-capable, the CLI owns the in-flight request, and every spawn env arms the CLI's own dead-stream machinery (ProfileManager.get_env_for_profile → ensure_stream_liveness_env): CLAUDE_ENABLE_STREAM_WATCHDOG=1 + CLAUDE_STREAM_IDLE_TIMEOUT_MS=420000 (7 min) make the CLI abort a stream with no chunk for 7 min (TTFB counts, every chunk resets it) and fall into its non-streaming fallback, with each failed attempt announced on stdout as system/api_retry; CLAUDE_CODE_INCLUDE_PARTIAL_MESSAGES=1 forwards every raw SSE part (message_start / deltas / pings) as a stdout line, so a healthy compose bumps updated_at per part instead of going silent. The reaper only fires after true stdout silence has outlasted that watchdog bound + recovery headroom (~200 s retry backoff + 60 s poll + 60 s heartbeat throttle) — 13 min with presets — instead of the earlier API_TIMEOUT_MS + 2 min (~52 min), which only bounded the headers phase. A slow first token is structurally inside the bound: a healthy agent composing a long report after a large tool_result can be silent for minutes (observed 3–4 min live), and updated_at staleness alone can't distinguish it from a dropped stream. When there is no session, the process has exited, or the process is past its turn terminator (stdin closed — the post-close kill escalation in app/ai/claude_backend_turns.py disposes of it), nothing can bump updated_at any more and the flat 3-min silence rule applies. Pending AskUserQuestions are always skipped (the operator may take hours).
Ziniao on WSL¶
WSL cannot auto-launch Ziniao because Electron's Node.js V8 rejects unknown -- flags before the app code runs. The workflow is:
- User downloads
ziniao_webdriver.batfromGET /api/ziniao/launcher(or uses the copy in the repo root) - User double-clicks the
.baton Windows — it auto-finds the exe, kills any existing Ziniao, launches in WebDriver mode, and verifies the HTTP API - Once Ziniao is running, WSL connects via the gateway IP (auto-detected by
_get_ziniao_host()fromip route)
If Ziniao is already running with the correct port, everything works automatically. The backend only raises errors when Ziniao is unreachable, guiding the user to the launcher script.
CDP Proxy Architecture¶
Each Ziniao store gets a stable CDP multiplexing proxy (app/browser/cdp_mux_proxy.py) that listens on 127.0.0.1:{proxy_port} and connects upstream (WebSocket) to the actual Ziniao debuggingPort (which changes on each startBrowser call). On WSL, the proxy connects to the Windows gateway IP instead of localhost. The browser-use wrapper's BU_CDP_WS env var (0.13; was --cdp-url) always points to the stable proxy port with a per-task client ID.
Multi-client support: Multiple browser-use CLI processes connect simultaneously, each via ws://127.0.0.1:{proxy_port}/client-{task_id}. The proxy multiplexes all clients onto a single upstream WebSocket to the browser, using:
- Request ID rewriting: Client request IDs are remapped to globally unique IDs; responses are routed back to the originating client
- Session-based event routing: CDP sessionId → owning client mapping ensures page events only reach the tab owner
- Target filtering: Target.getTargets() responses filtered per-client; cross-client attachToTarget/closeTarget blocked
- Pending event cache: Handles the race where Target.attachedToTarget arrives before Target.createTarget response (common with browser-use's setAutoAttach)
Lifecycle: The proxy stays alive while any task uses it. Client disconnect (clean or crash) triggers cleanup: close the client's tabs, remove routing entries. Startup cleanup closes orphan tabs from prior server crashes.
A legacy TCP relay (CDPTcpProxy in cdp_proxy.py) is kept as fallback for single-client scenarios.
Soft Stop-Gates¶
When the agent calls vibe_seller_set_task_result, the resolved result text runs through a small chain of soft gates before the task is allowed to settle. Each gate is a function that returns either None (allow) or a GateDeny with a short, agent-readable reason and a gate identifier. Code lives under app/ai/stop_gates/; the dispatch is in app/routers/tasks.py::set_task_result.
Why soft, not hard¶
Gates can be wrong, the model can be stubborn, and trapping the agent forever in a deny loop wastes tokens. Each gate is allowed at most SOFT_GATE_MAX_DENIALS denials per task (the agent sees the deny reason and can retry); past the cap the result is allowed through with the original text and a logger.warning(...). Deny counts are tracked in-memory by task id (record_attempt in app/ai/stop_gates/__init__.py); the current cap value lives there alongside the rationale.
Why at the MCP tool call, not the Stop hook¶
Some backends (notably DeepSeek under certain compaction conditions) never emit a Stop event, so a Stop-hook-only gate would be silently skipped. set_task_result is the one tool every successful task path calls, so the gate fires there.
Bundled gates¶
| Module | Concern | Body of the check |
|---|---|---|
app/ai/stop_gates/markdown_format.py |
Final result is well-formed Markdown — no leftover XML-style closing tags, no truncated fences | Regex scan on the resolved text |
app/ai/stop_gates/result_language.py |
Reply language matches the user's task language (the agent shouldn't answer a Chinese task in English) | Character-set heuristic over the prompt + result, with carve-outs for technical identifiers |
Adding a gate¶
- Drop a module under
app/ai/stop_gates/exporting acheck(text, …) -> GateDeny | Nonecallable. Free to take extra inputs (e.g.result_language.checkalso takes the task title + description). - Wire it into the dispatch loop in
set_task_result(for gate_module, gate_args in (…)). - Add a unit test under
tests/unit/test_stop_gates.py.
The dispatch loop deliberately runs gates in declaration order and short-circuits on the first deny — so put cheaper / more important gates first.
Plugin Architecture¶
The codebase is split into a public OSS core (vibe-seller, knows no
customer) and private per-customer plugins (external wheels, on top of
core). app/plugins.py is the inversion-of-control seam that makes this
possible: core reads its gates, PreToolUse guards, browser backends,
skill sources, prompt fragments, and background services from a registry
that plugins populate at startup, rather than importing customer code
directly. Removing a customer = not installing its wheel — zero core
edits, nothing to re-leak. (Design-of-record: plugin_design_v2.md;
module reference: backend.md § Plugin Framework.)
The contract¶
A plugin subclasses Plugin and implements install(ctx), calling the
ExtensionContext.register_* methods. Registration is declarative —
install records contributions into ctx and never touches the live
FastAPI app, so the registry can be built lazily in app-less unit tests.
App-level effects are applied separately by reading the populated
context: main._wire_plugins mounts plugin routers / frontend-bundle
routes; the lifespan starts/stops background services.
| Contribution | Registry method | Read by |
|---|---|---|
Stop-gate (set_task_result reviewer) |
register_gate |
stop_gates.get_registered_gates → resolve_skill_gates (see Soft Stop-Gates) |
| PreToolUse Bash guard | register_pretool_gate |
bash_safety.first_bash_deny |
| Browser backend | register_browser_backend |
BrowserManager._get_backend |
| Skill directory | register_skill_source |
skills_sync (core app/skills is always first; first-source-wins on a name collision so a plugin can't shadow a core skill) |
| System-prompt fragment | register_prompt_fragment |
prompt assembly (forward-looking) |
| Background service | register_service |
lifespan (started as a task, cancelled on shutdown) |
| Server-reviewed audit marker | register_review_marker |
bash_safety.check_review_status (a plugin with its own server-side completeness gate suppresses core's legacy REVIEW-file Stop gate for its audits — no double-gating) |
| Backend router / frontend JS bundle | register_router / register_frontend_bundle |
main._wire_plugins + GET /api/plugins |
OSS builtin vs plugins¶
The OSS core registers itself as the builtin plugin
(app/builtin_plugin.py:BuiltinPlugin) through the exact same API a plugin uses — dogfood, and the registry is never empty. It is loaded by
direct import (always present, no entry point needed) and fail-closed:
if it can't load, startup aborts rather than running with no safety
gates. External plugins are discovered via the vibe_seller.plugins
entry-point group their wheels declare, loaded after the builtin in
deterministic (name-sorted) order, and fail-open (a bad wheel is
logged and skipped). Per-customer isolation is at pack/install time:
a customer's box installs only that customer's plugin wheels, so other
customers' code is absent at the wheel boundary.
Frontend extension¶
A plugin can add dashboard UI without forking the OSS frontend: it
registers a compiled JS bundle (register_frontend_bundle), GET /api/plugins
lists the active bundles, and the OSS React shell dynamically loads each
one (requires_early_init bundles resolve before the app makes API
calls). Ships inert in PR-1 (empty list in an OSS-only install); the
React loader lands when the first plugin UI does.
Amazon Ads API service¶
A store can do its advertising over a hosted ads service instead of the browser. vibe-seller holds no Amazon credential and speaks no Amazon API: the service holds the Amazon authorization and does the calls.
- Binding (
app/ads_client.py): nothing to configure. The service address is the constantSERVICE_URL, and the installation registers itself (POST /installations) the first time Settings → Integrations syncs the stores — never at boot, so an installation that does not use ads never contacts the service. The key is stored encrypted inAppSettingsand never returned by any route. The key alone reaches no store: each store is reached only after its owner consents on Amazon, and is revoked per store (POST /api/ads/stores/{id}/unbind, admin only). - One advertiser, several installations: Amazon's profile id says two stores are the same advertiser (names and local ids are each machine's own). Each installation that the owner consented from reads the same history; revoking on one leaves the others working.
- Write-back: an authorized store's marketplaces are added to its
platforms/countries/platform_countries— Amazon's word beats a guessed list. Nothing is removed, on sync or on revoke. - Key secrecy: the agent's one tool,
vibe_seller_ads_call, goes throughPOST /api/ads/call, which adds the key server-side. The key never enters the agent's context, and paths that belong to a person (/me,/auth-url,/assignments,/ads-binding,/installations) are refused. - Authorization: the OAuth round trip happens on the service's domain,
so a deployment needs no public address.
POST /api/ads/stores/syncrecords which stores are authorized:Store.ads_store_keys({marketplace: store_key}) andStore.ads_authorized. - store_key resolution: the agent never supplies a store key. The call
names a marketplace;
resolve_store_key()maps it to the key, and refuses rather than guesses when a store has several marketplaces and none was named. A task bound to a store reaches only that store. - File answers: the service answers a large list as CSV; it is saved
to
tasks/<id>/ads-data/and the agent gets the path and row count. - Skill routing: an authorized store's task sees
amazon-ads-apionly, any other task the browseramazon-ads. See workspace.md § Ads skill routing, which also covers how the service-shipped skill is synced.
Cross-platform support (native Windows)¶
Vibe Seller runs on macOS, Linux, and natively on Windows (no WSL
required). Every platform difference is centralised in app/platform.py
(see backend.md) so the rest of the code stays
platform-agnostic:
- Process management —
kill_process,is_process_alive,find_processes_by_patternusepsutilinstead ofpgrep/ps/os.kill(Unix-only). The agent's_force_killsignals the POSIX process group viaos.killpgwhere available and falls back toterminate()/kill()on Windows (wheresend_signal(SIGINT)raises). - Paths —
venv_python/venv_executable/venv_bin_dirresolveScripts/on Windows vsbin/on POSIX;prepend_to_pathjoins withos.pathsep(;on Windows).safe_chmodis a no-op on Windows. - Agent shell — the agent invokes
browser-usethrough Claude Code's Bash tool, which uses Git Bash on Windows. So the existing bash per-store wrapper (app/browser/wrapper.py) works unchanged on every platform — there is no PowerShell wrapper.
Native Windows installer¶
installer/windows/ builds a self-contained VibeSeller-Setup.exe
(Ollama-style: per-user install + a system-tray launcher that starts the
server on login). It bundles a relocatable CPython
(python-build-standalone), the app + deps as offline wheels, uv,
full Git for Windows (git + the bash.exe Claude Code's Bash tool
needs, plus curl/perl for the browser-use wrapper), and the claude
CLI. It
drives the user's installed Chrome — no bundled browser. Built on a
GitHub-hosted windows-latest runner
(.github/workflows/windows-installer.yml); see
installer/windows/README.md.