中文English

GatherSurf App Development Guide

Every API in these docs has runnable code in the example project “Sourcing Assistant”. Pick “📘 Example project” when you create an app and you get it — a complete, working app you can edit and publish.
The project also ships an AGENTS.md that you can feed straight to an AI and have it changed to fit your needs (see Build with AI).

What this is

You can build your own apps inside the GatherSurf client, and they run on exactly the same path as the apps we publish ourselves — there is no “developer-mode privilege”, and no “restriction that only appears after listing”. That constraint is deliberate: otherwise you get “works on my machine, breaks once listed”, and that kind of problem is the hardest to track down.

What you writeWhere it runsWhat you get
main.jsClient main processhost object — scoped to the permissions you declared
ui/Client UI (the same window)gsApp object — and nothing else
Why the UI cannot reach anything else

An app's UI is a fragment mounted straight into the host page (not an iframe). The upside: you can use the host's CSS variables directly, so theme and colours follow automatically, and there is no message bridge to build. The price is that isolation rests on convention — your script runs inside a function scope, and the only global is gsApp, which cannot reach any host internal state.

🔎 I want to…

These docs are organised by API, but what you have in mind is usually one task. Match it here first, then jump straight to the part you need.

I want to…Go to
Have AI write it — I only describe what I want Build with AI — create an app project and say one sentence
Keep something for next time (settings, results) Up to a few dozen rows: storage (which is just reading and writing files). For queries, aggregates, thousands of rows → db
Call someone else's API for data http — just use it, no domain registration needed. IPs, LAN addresses and services on localhost all work
List the user's browser profiles / create / edit them profiles (read, open-close, and write are three separate permissions — ask for what you need)
Open a profile, click and type on the page, scrape data automation; for multi-step work, use runSteps to run an RPA flow — more reliable
Do something long-running (bulk profile creation, speed-testing one by one) Progress events —— must be pushed as you go — otherwise the UI sits still for tens of seconds and the user thinks it has hung
Use Bootstrap or drop in existing HTML Writing the UI — one line in the manifest gives you the three frameworks bundled with the platform
Let the user pick a file / export results as CSV files:pick
I'm done — how do others get it Spec checkThree ways to publish
I'm not writing an app; I just want to drive the client from outside (Python/Node) External API — that is a different thing entirely and unrelated to the above
Skim this before you start Pitfalls at a glance

Everything in that section is a problem that does not raise an error — avoiding them while you write costs far less than a day of debugging afterwards. What they share: it looks like it is working, and it is not.

Running in five minutes

Two paths — pick one. Neither needs you to install anything — the client itself is the development environment.

Path A: let AI write it (no JS required)

  1. Left sidebar → “✨ AI Dev” → “+ New app” at the top right
  2. Give it a name, and fill in only the second half of the identifier — the prefix is added for you
  3. Say what you want in plain language:
    “Build a proxy speed-test board: list all my proxies, test them all on one click, show latency and whether they work, allow sorting by latency, and mark the dead ones red”
  4. It will first ask which UI framework to use — just pick one (take the recommended one if unsure)
  5. When it is generated, click “▶ Preview” to run it and see the result
  6. Not happy? Keep talking: “add a region column”, “test 5 at a time”
  7. Happy? Click “📦 Add to My apps” and it becomes a real app you can publish

Path B: write it yourself

  1. “Apps” → “🛠 My apps” → “+ New app”
  2. Choose “📘 Example project” — that is a complete, working app with a clickable button for every API
  3. Click “Open” and run through it to see which API you actually need
  4. “📂 Open dev folder”, then edit with your usual editor main.js
  5. Save, go back to the client — it reloads automatically, no restart needed
The smallest possible app is only this much
main.js                                 // backend
  exports.register = function (host) {
    host.ipc.handle('hello', async () => ({ ok: true, msg: 'hello' }));
  };

ui/index.html                           // UI (a fragment — no html/body)
  <button id="ab-go">Click me</button><div id="ab-out"></div>

ui/app.js                               // UI logic (must be an IIFE)
  (function () {
    document.getElementById('ab-go').onclick = async () => {
      const r = await gsApp.invoke('hello', {});
      document.getElementById('ab-out').textContent = r.msg;
    };
  })();

The rest, manifest.json (declaring permissions and entry points) and README.md, are mandatory and are generated for you when you create the app.

What if auto-reload doesn't kick in

First try “Reload manually” under ⚙ Settings. If that still does nothing, the file you edited is most likely not in the dev folder — the path shown on the right of the toolbar is the one the client actually reads.
“I changed the file and it still runs the old code” is one of the hardest problems to notice: you add a log line, it doesn't appear, and you start suspecting the logging code rather than suspecting that the file never took effect.

Build with AI

The example project ships an AGENTS.md that is written specifically for an AI to read: every API signature, the hard constraints, the common mistakes, and how to change and publish — all in one file.

Three steps

  1. Pick “📘 Example project” when creating an app, and you get a complete, working codebase
  2. Click “📂 Open dev folder” and hand the whole folder (including AGENTS.md) to your AI
  3. Just say what you want — “swap 1688 for Taobao”, “add an automatic price-drop alert”, “export to Excel instead”
Why AGENTS.md exists instead of letting the AI read the code

Half of what matters is not visible in the code: which globals are shadowed at runtime, how the style prefix is derived, what the spec check rejects, host.db why you must add LIMIT, and whether the allowlist currently records or blocks. Left to guess, an AI writes code that is syntactically perfect and cannot be packaged.

An opening prompt you can paste as-is

This is an app project for the GatherSurf client.
Read AGENTS.md first — it states every API, the hard constraints and the common mistakes.
Then change it to match my requirements. Note:
  - do not require('electron') / require('fs')
  - every CSS selector must carry the prefix already used in this project
  - ui/app.js must stay an IIFE
  - schema changes may only append a new version inside ensureSchema(); existing ones must not be edited
My requirement is: ______
What to do after the AI is finished

Click Spec check under ⚙ Settings. It checks 25 things a machine can decide — what an AI misses most often is the style prefix and the IIFE, and the check points at both immediately.
Once it passes, click “Open” and actually run it: the check only covers form, not whether your logic is right.

Folder layout

your-app/
├─ manifest.json     who you are, what permissions you need, where the entry points are
├─ main.js           backend logic (main process)
├─ README.md         mandatory — the spec check looks for it
└─ ui/
   ├─ index.html     UI fragment (not a complete document)
   ├─ style.css      styles (every selector carries the prefix)
   └─ app.js         UI logic (must be an IIFE)
No node_modules

An app package carries no dependencies. You may only use Node's pure-computation built-ins (path, crypto, url and similar); you may not require third-party packages, nor may you require built-ins that do I/O — for files use host.storage and for the network use host.http.

manifest.json

{
  "key": "abc12-erp-sync",        // your app key — cannot be changed once created
  "name": "ERP Sync",
  "icon": "🚀",                   // emoji, or a path to an image inside the package
  "version": "1.0.0",             // must be x.y.z
  "description": "one-line description",
  "apiVersion": 1,
  "minClientVersion": "0.3.10",   // clients older than this cannot install it
  "permissions": ["storage", "db", "http"],
  "http": { "allow": ["api.mycompany.com"] },   // optional; if the target domain is fixed, declare it truthfully
  "entry": { "main": "main.js", "ui": "ui/index.html" }
}
The key cannot be changed once created

It is three things at once: the local folder name, the OSS path after listing, and the IPC routing key. The prefix (your account namespace) is mandatory — without it, two customers who create an app with the same name overwrite each other, and silently: customer B installs customer A's code.

Permissions

PermissionWhat it gives youNotes
storagehost.storageAlmost always needed
dbhost.dbRequires a paid plan
httphost.httpAllowlist is optional; if the target domain is fixed, you should declare it.
profiles:readhost.profiles Read only
profiles:controlAdds open/close
profiles:writeCreate / edit / delete profiles, configure proxiesCan delete profiles — irreversible
automationhost.automationA key focus of review
files:pickhost.filesCan only open a picker for the user
For a permission you did not declare, the matching object is simply undefined

so check before you use it. This is not pedantry — the user may be running a client without the business plan, in which case host.db is empty and using it directly throws Cannot read properties of undefined.

if (!host.db) return { ok: false, error: 'no db permission' };

The platform also puts capabilities you “declared but did not get” into host.locked, together with the reason and how to unlock them. Showing that in your UI is far better than leaving the user with a button that does nothing.

API cheat sheet

CapabilityMethod
host.ipc
No permission needed
handle(name, fn)send(name, payload)
host.storage
storage
Synchronous
dir()list()read(n)readJson(n, d)remove(n)write(n, t)writeJson(n, o)
host.db
db
Synchronous
close()exec(sql, params)migrate(list)path (value)query(sql, params, o)tx(fn)
host.http
http
all need await
allowed()fetch(url, init)json(url, opt)mode()request(url, opt)
host.profiles
Per method — see right
all need await
addProxy(input) profiles:writecheckProxy(id) profiles:readclose(id) profiles:controlcreate(input) profiles:writeget(id) profiles:readgroups() profiles:readlist() profiles:readopen(id) profiles:controlproxies() profiles:readremove(id) profiles:writerunning() profiles:readupdate(id, input) profiles:write
host.automation
automation
all need await
closeTab(id, tabId)dwell(id, opt)evaluate(id, expr, opt)newTab(id, url)realClick(id, x, y, opt)realKey(id, key, opt)realType(id, x, y, s, opt)runSteps(id, steps, vars)screenshot(id, opt)tabs(id)
host.files
files:pick
all need await
pickOpen(opts)pickSave(opts, data)
Values
No permission needed
host.apiVersion host.appKey host.canDevelop host.config host.locked host.log() host.plan

This table is generated directly by platform code — it is the same one the AI assistant is given. A method that is not in it does not exist: writing it gets you undefined, and at runtime is not a function.

Backend: host.ipc

Example app: main.js, section 0

The platform calls exactly one export of yours:

exports.register = function register(host) {
  const { ipc, storage, db, log } = host;

  ipc.handle('hello', async (payload) => {
    log('received', payload);        // written to the client log, for debugging
    return { ok: true, msg: 'hello' }; // goes straight back to the UI
  });
};

// optional, but strongly recommended
exports.deactivate = function () { /* stop your timers */ };
Convention: return {ok:false, error:'plain language'} on failure, don't throw

If you throw, the UI only sees a framework error message — the user has no idea what happened, and you get no context either.

deactivate must actually stop your timers

If it doesn't: the user closes the app UI while your scheduler keeps running in the background, still driving their profiles. Review looks for this one specifically.

UI: gsApp

Example app: ui/app.js
MemberWhat it is
gsApp.appKeyYour app key
gsApp.configPer-customer config from the cloud (it can differ per customer)
gsApp.invoke(name, payload)Call the backend; returns a Promise
gsApp.on(name, fn)Receive events pushed from the backend
gsApp.toast(msg, isErr)Show a toast
gsApp.openExternal(url)Open a link in the system browser
const r = await gsApp.invoke('hello', { name: 'Alice' });
if (r.ok) console.log(r.msg);
else gsApp.toast(r.error, true);

Progress events

Example app: main.js section 8 / bottom of ui/app.js

A long task must not stay silent until it finishes — the user will assume it has frozen.

// backend
ipc.send('progress', { i: 3, total: 10, msg: 'step 3 done' });

// UI
gsApp.on('progress', (p) => {
  bar.style.width = (p.i / p.total * 100) + '%';
});

File storage storage

When to use it For up to a few dozen items: settings, the last choice, a snapshot of results. It is just reading and writing files, and it is synchronous — do not await it.
When not to use it When you need queries, aggregates, or thousands of rows — use db instead. storage can only read everything out and filter in JS, which stalls once the row count grows.
Example app: main.js, section 1

Everything is synchronous — do not add await.

storage.dir()                      // absolute path of your data folder
storage.list()                     // which files you have stored (array of names)
storage.read(name)                 // text; null if it cannot be read
storage.write(name, text)          // atomic write (.tmp first, then rename)
storage.readJson(name, fallback)   // returns the fallback if missing or corrupt
storage.writeJson(name, obj)
storage.remove(name)               // delete; returns true / false
write's second argument must be a string

To store an object use writeJson; to delete a file use remove. write(name, null) is not how you delete — it throws TypeError.

The path is assembled by the host; you supply only a file name

Passing ../../someone-elses-file is rejected. The platform builds the path, so “escaping the folder” is architecturally impossible and does not depend on code review to catch.

When to switch to the database

As soon as you need queries, sorting, aggregation, or pagination. Once JSON passes a thousand rows you are reading the whole file into memory on every access and filtering it yourself, while SQL uses an index and answers in microseconds. That line arrives sooner than you would expect.

Database db Requires a paid plan

When to use it When you need queries, sorting or aggregation, or once you pass a thousand rows. It is SQLite, and it is synchronous, running in the main process.
When not to use it To remember “which one the user picked last time” — use storage for that; it is not worth a database for one field.
Example app: main.js, section 2 (2.1 – 2.7)
Read this one first: it is synchronous and runs in the main process

However long a query takes, the whole client is frozen for that long — every profile operation stops. Measured on a 50,000-row database: an aggregate report at 13 ms and an indexed lookup at 0.21 ms are imperceptible, but SELECT * pulling 50,000 rows at once takes 162ms and freezes the main process completely.

So the platform enforces a row cap: returning more than the cap raises an error rather than truncating silently — silent truncation makes your report compute a wrong total, which is far more dangerous than an error.

=> Leave aggregation to SQL (SUM / COUNT / GROUP BY); do not pull rows back and compute yourself.

API

db.query(sql, params)      // read; returns an array of rows (subject to a row cap)
db.exec(sql, params)       // write; returns { changes, lastId }
db.tx(fn)                  // transaction; a throw inside fn rolls everything back
db.migrate(list)           // versioned table creation / alteration
db.path                    // path to the database file

Creating tables: use migrate, don't run CREATE TABLE by hand

db.migrate([
  { v: 1, name: 'create_customers', up: [
      `CREATE TABLE IF NOT EXISTS customers(
         id     INTEGER PRIMARY KEY AUTOINCREMENT,
         name   TEXT    NOT NULL,
         amount INTEGER NOT NULL DEFAULT 0,   -- amounts as integer cents
         created_at_ms INTEGER NOT NULL       -- milliseconds; store Date.now() directly
       )`,
      `CREATE INDEX IF NOT EXISTS idx_customers_at ON customers(at)`,
  ]},
  { v: 2, name: 'add_remark', up: [`ALTER TABLE customers ADD COLUMN remark TEXT`] },
]);
A migration that has shipped cannot be changed — you can only append after it

A user's database may sit at any version — they installed v1 last month and upgrade to v3 today, and migrate fills in exactly the steps they are missing. Editing a historical migration means older users cannot upgrade, and the failure happens on their machine where you cannot see it.

CRUD

// Always use ? placeholders; never build the string yourself.
// The problem is not only injection — one apostrophe in a name breaks your SQL.
const r = db.exec('INSERT INTO customers(name,amount,at) VALUES(?,?,?)',
                  ['Alice', 12800, Math.floor(Date.now()/1000)]);
// r.lastId is the autoincrement id

const rows = db.query('SELECT * FROM customers WHERE amount > ? ORDER BY id DESC LIMIT 50',
                      [10000]);   // ★ always LIMIT

db.exec('UPDATE customers SET amount=? WHERE id=?', [20000, r.lastId]);
db.exec('DELETE FROM customers WHERE id=?', [r.lastId]);

Hard limits

ItemValueWhat happens if you exceed it
Rows returned by one query20000error, not silent truncation
One app's database file512 MBwrites are refused
One string / blob64 MBerror (don't put large files in the database — store the path with storage)
Why exceeding the row cap is an error instead of handing you the first 20,000 rows

Silent truncation makes your report produce a plausible-looking but wrong total — far more dangerous than an error. Paginate if you need everything, and leave aggregation to SQL.

Aggregation: let SQL do it

const byCity = db.query(`
  SELECT city, COUNT(*) AS n, SUM(amount) AS total
  FROM customers GROUP BY city ORDER BY total DESC LIMIT 20`);

Transactions

try {
  db.tx(() => {
    const a = db.query('SELECT amount FROM customers WHERE id=?', [from])[0];
    if (a.amount < amt) throw new Error('insufficient balance');   // throwing = full rollback
    db.exec('UPDATE customers SET amount=amount-? WHERE id=?', [amt, from]);
    db.exec('UPDATE customers SET amount=amount+? WHERE id=?', [amt, to]);
  });
} catch (e) {
  // on arriving here the data is exactly as it was before the transfer
}

When you must use one: several writes that are one thing in business terms. The classic case is “subtract on one side, add on the other” — a crash in between makes money vanish. The example app has a “fail on purpose (verify rollback)” button so you can see it for yourself.

Where the database lives

One separate file per app, locked inside your own data folder, untouchable by other apps. Deleting an app deletes only the source and keeps the data folder — so deleting the code by mistake does not lose data.

Outbound HTTP http

When to use it Calling someone else's API, or syncing data to your own server. Domains, IPs, LAN addresses such as localhost all work, and no prior registration is needed.
When not to use it When you want to scrape content rendered on a web page — for that use automation to open the page. http gives you the raw HTML, and on many sites the content is rendered by JS.
Example app: main.js, section 3

In the manifest, declaring the "http" permission is all you need. The domain allowlist is optional (see “The domain allowlist is optional” below):

"permissions": ["http"]

// to tell users where you will connect, add this line as well (optional)
"http": { "allow": ["api.mycompany.com", "192.168.1.10:8080"] }

Three entry points — pick by what you want:

// ① fetch JSON — the common case
const r = await http.json('https://api.mycompany.com/orders');
r.data          // ← the parsed object is here
r.status        // 200

// ② when you need the status code / headers, or the other side may not return JSON
const r2 = await http.request('https://api.mycompany.com/ping');
r2.status  r2.ok  r2.headers  r2.text

// ③ same signature as standard fetch — for streaming or special uses
const r3 = await http.fetch('https://api.mycompany.com/orders', {
  method: 'POST',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify(data),
});
const text = await r3.text();

http.allowed()   // which domains are on the allowlist — you can show this to users
http.mode()      // the current allowlist enforcement mode
json()'s parsed result is in .data, not the return value itself

Writing const data = await http.json(u) and then reading data.xxx gives you undefined everywhere, and it raises no error. The correct form is const r = await http.json(u); r.data.xxx.

Also: when the other side does not return valid JSON, json() will throw (request() does not). If you are unsure what they return, use request() and decide yourself.

The allowlist takes domains only

"api.mycompany.com" — no protocol (https://), no path, and no top-level wildcard such as *.com. A form with a protocol is compared against the URL's hostname, never matches, and the result is that every request is treated as outside the allowlist. The spec check (devcheck) rejects these forms.

Why an allowlist at all

What is restricted is not outbound traffic — a customer's data should be able to reach their own systems. What is restricted is outbound traffic nobody can see. The domains sit in the manifest, visible to listing review and visible to the customer at install time.

Redirects: followed inside the allowlist, not outside

When the other side returns 301/302:

Following a redirect to an already-authorised domain adds no exposure — the app could have requested it directly. The real risk is leaving the allowlist: a domain you trust returns a 302 pointing elsewhere, and the data goes out with it.

★ You will hit this in practice: GitHub's api.github.com/repos/facebook/react returns a 301 pointing at itself (the repository was renamed). Not following it means you get no data.

Hard limits

ItemValueWhat happens if you exceed it
One response body8 MBerror (paginate or chunk large files)
Rate600 requests / minutereports “too many outbound requests”
Timeout30 secondsthe request is aborted (adjustable via opt.timeout)

★ This is written down because if you only learn the number by hitting it, your first reaction to the error is “my code is wrong”, not “I went over a limit”.

The domain allowlist is optional

You can request any address without declaring one

Domains, IPs, LAN addresses like 192.168.x.x:8080, localhost — all directly reachable, with no prior registration. host.http.mode() returns the current mode, which today is permissive: domains outside the allowlist are only recorded, not blocked.

Then what is it for?It is there for people to read — users see “where this app will connect to” at install time, and listing review reads it too. If you write one, write it truthfully: list exactly what you use.

★ One exception: "*" and "*.com" forms like this are rejected. An allowlist that permits everything is more misleading than none at all — whoever reads it assumes a restriction is already in place.

But that half is already enforced: bare fetch does not work

inside an app scope, fetch / XMLHttpRequest / WebSocket is shadowed, and calling it tells you to use host.http instead. globalThis.fetch behaves the same — what is shadowed is the lexical scope.

Why both halves have to happen together: adding an allowlist without removing bare fetch lets an app simply bypass it, which makes the allowlist meaningless and invisible to review. Doing half of it is the same as doing none of it.

Profiles profiles:read profiles:control profiles:write

When to use it Listing the user's browser profiles, opening and closing them, creating and editing them. Permissions come in three levels (read / control / write) —ask for what you need; asking for more invites questions at review.
When not to use it You cannot get proxy passwords or platform account passwords, and fingerprints are generated in the cloud — you take no part in that.
Example project: the “Profiles” page
// profiles:read — reading
await profiles.list()      // [{ id, customNo, name, group, platform, lastOpenedAt, locked }]
                           //   lastOpenedAt = a second-level timestamp; null if never opened (×1000 before new Date in JS)
                           //   locked = true means over the plan quota — this profile cannot be opened (the data is still there)
await profiles.get(id)
await profiles.running()   // an array of id strings: ["p1","p2"], not objects

// profiles:control — acting on existing profiles
await profiles.open(id)
await profiles.close(id)

// profiles:write — changing what the account contains
await profiles.create({ name, group, platform, proxyId, proxyLine, remark })
await profiles.update(id, { name, group, remark, proxyId })
await profiles.remove(id)                    // irreversible
await profiles.proxies()                     // proxy library — only needs :read (it merely lists)
await profiles.addProxy({ name, line })       // line = socks5://user:pass@ip:port
await profiles.checkProxy(id)             // → { ok, ip, country, ms, error }; reachability + latency
                                          //   ★ only needs profiles:read (it changes nothing)
await profiles.groups()                      // [{ id, name, count, builtin?, orphan? }] — only needs :read
                                          //   ★ it contains two kinds of row that are not real groups — see below
groups() does not return only “groups you created”

Three kinds of row, told apart by field (since 2026-08-05):

Renaming or deleting either of these is rejected (400). To iterate and delete groups, filter first: groups.filter(g => !g.builtin && !g.orphan).

★ Why they are returned at all: without them, the per-group count values add up to less than the total number of profiles, and the missing profiles cannot be found in any group — one account was short by 8, with no error anywhere.

running() returns an array of ids, not of objects

To close every window, iterate it directly: for (const id of await profiles.running()) await profiles.close(id). Writing .map(p => p.id) gives you a list of undefined, raises no error, and every subsequent close(undefined) silently fails — not a single window closes.

Proxies can be added but not deleted

addProxy A proxy you add stays in the account's proxy library; this API has no delete entry point. Keep that in mind while testing or trialling, and don't pile up junk in a customer's account.

Why control and write are separate permissions

control is “operate profiles that already exist”; write is “change what the account contains” — creating consumes quota, deleting is irreversible, and changing a proxy changes the exit IP. Merged into one, an app that only wants to open and close windows would be forced to hold the power to delete profiles, and the permission summary the user sees at install time would stop telling them apart.

You do not handle fingerprints when creating profiles

Fingerprints are generated in the cloud. You only say which platform, which group and which proxy; the platform does the rest — that keeps the fingerprint strategy uniform across one account, and stops a single app from generating something wrong and giving the game away.

Pass quota errors through unchanged

Creating a profile hits three cloud gates (not signed in / no permission / over the limit). Wrap them into a single “creation failed” and the user has no idea whether to upgrade the plan or delete a few profiles first.

Use the one-line proxy string; don't parse it yourself

await profiles.addProxy({ name: 'HK node', line: 'socks5://user:[email protected]:1080' });

The cloud parses it into type/host/port/user/pass. If you parse it yourself, one change on either side and the two disagree — and disagreement shows up as “the proxy is configured and has no effect”, which is painful to track down.

You never get passwords

Proxy passwords and platform account passwords never cross this boundary — not hidden by the front end, simply never returned by the API.

Page actions automation A key focus of review

When to use it Opening a profile, then clicking, typing, scrolling, screenshotting and scraping on the page.
When not to use it For multi-step work, prefer running an RPA flow with runSteps — far more reliable than stitching evaluate + realClick yourself, and much shorter.
Example app: main.js, section 5

The first argument is always the profile id. You must profiles.open(id).

await automation.newTab(id, url)          // → { id, url }; http/https only
await automation.tabs(id)                 // → [{ id, url, title }]
await automation.closeTab(id, tabId)      // → { ok, closed } / { ok:false, why }; close it when done
await automation.evaluate(id, 'document.title')   // → the value of the expression itself (no return)
await automation.realClick(id, '#su')     // pass the selector directly
await automation.realType(id, '#kw', 'text')
await automation.realClick(id, x, y)      // coordinates also work (rarely needed — see below)
await automation.realKey(id, 'Enter')
await automation.screenshot(id, {})       // → { ok:true, base64:'…' }
await automation.dwell(id, { scrolls: 4 })        // → { dwelled, scrolls, wander }; human-like dwell
profiles.open() returning ≠ the window is usable

A cold kernel start takes ten-plus seconds on a busy machine. Do not write a fixed sleep(3000) — you wait for nothing when it is fast, still fail when it is slow, and it breaks on a different machine. Poll with tabs(id): once you get an array, it is up.

There is another kind of “won't open”: it shouldn't (since 2026-08-05): when the profile count exceeds the plan quota, only the newest N can be opened; the rest keep all their data but refuse to start (they are the ones with list() in locked: true). The error tells you whether it is “plan expired” or “simply over quota”. Filter by locked before bulk-opening — without it, every one fails inside your loop, and the reason is mixed in with “the kernel didn't start”, which makes the real cause hard to see.

newTab After that, pass tabId through
const tab = await automation.newTab(id, url);
await automation.evaluate(id, expr, { tabId: tab.id });
await automation.realType(id, '#kw', 'text', { tabId: tab.id });

Without it the platform falls back to “the one you opened most recently”, which is right almost always. But when a window opens it restores the previous tabs asynchronously, and on a slow machine those land afterwards — passing tabId explicitly is the deterministic option.

(Hit in practice: probing for an input box found a blank page left over from the previous round, reported “no visible input box found”, and was intermittent — depending on whether the restore was fast or slow.)

Don't write a site's selectors from memory — probe first

Sites get redesigned, and the most common outcome afterwards is not “element not found”; it is that the element is still in the DOM but is now a 0×0 hidden leftover: your code “succeeds” every step of the way while the input box stays empty.

Measured 2026-08-03: Baidu's #kw / #su is exactly this kind of leftover now — the real input is #chat-textarea and the button is #chat-submit-button, while is what every model's training data contains, so #kw an AI is bound to get it wrong.

// probe once and use what you found
const sel = await automation.evaluate(id, `(() => {
  for (const el of document.querySelectorAll('input,textarea')) {
    const b = el.getBoundingClientRect();
    if (b.width > 60 && b.height > 16 && el.id) return '#' + el.id;
  }
  return '';
})()`);
await automation.realType(id, sel, 'fingerprint browser');

width>60 && height>16 This filter is the important part: it drops the hidden leftovers outright.

Never hard-code coordinates

realClick(id, 300, 200) Clicking empty space raises no error — every step “succeeds”, nothing happens. Normally you only know the selector, so pass the selector: the platform resolves the element rectangle and the landing point still drifts randomly inside it (not pixel-precise aiming — which is exactly why realClick exists).

runSteps: run an RPA flow directly

For multi-step work this beats stitching evaluate + realClick together yourself — more reliable and far shorter. The step format is identical to the blocks on the Automation page.

const r = await automation.runSteps(id, [
  { op: 'goto', url: 'https://example.com' },
  { op: 'waitForSelector', selector: 'h1' },
  { op: 'evaluate', code: 'return document.title' },
]);
if (!r.ok) return { ok: false, error: r.error };   // ← failure does not throw — you must check .ok yourself
r.results     // one object per step: [{ op, ok, value, error }, ...]
r.vars        // variables stored by saveTo during the flow
results is an array of step objects, not of values

r.results[r.results.length-1] gives you that object, not the result of that step. Comparing it as a number is always false — a success is judged a failure, while error is undefined and the UI is left with “unknown error” (hit in practice 2026-08-03).

For the value, use r.results[i].value. The sturdier approach is to give that step a saveTo:'name' and read it from r.vars.name afterwards — index-based reads shift by one the moment a step is inserted in the middle.

in RPA's evaluate you must write it yourself return

code is a statement block (executed inside an async function), so leaving out return gives you value: undefined while the step still ok: true — the result vanishes and nothing is reported.

{ op:'evaluate', code:'return document.querySelectorAll(".result").length' }

★ Note this is automation.evaluate(id, expr) the opposite of, which takes an expression — do not write return there. Same name, opposite convention: the easiest pair to confuse.

op field is mandatory — writing type or action gets you “unknown RPA step: undefined”.
Without checking .ok, every step in the flow can fail and you still only see “executed successfully”, because runSteps puts errors in the return value.

evaluate must be JSON-serialisable

Returning a DOM node gives you an empty object {}, with no error. Extract what you need from the element inside the expression: 'document.querySelector("h1").textContent'.

Only http/https are allowed; file:// is rejected

Otherwise an app holding nothing but the automation permission could use the browser to read any file on disk — bypassing the database sandbox entirely (it guards SQL, not the browser) — and credentials like .gs-auth along with it.
A sandbox is only as strong as the weakest capability granted alongside it.

Close tabs when you are done

Every tab is a renderer process. Run round after round without closing and memory climbs steadily.

Why realClick exists instead of doing it inside evaluate el.click()

Many sites do not accept synthetic events. Quantity inputs are the classic case: assigning with value= never reaches React's state, so the page shows the new value and submits the old one — with no error.

Walkthrough: open a window → go to 1688 → search → store results

Example app: main.js section 5.5 · the “★ Walkthrough” section of the UI

The previous sections cover how each API is used; this one shows what they look like strung together — this is the shape of the code you actually write for real work.

Seven steps

  1. Pick a profile — the first one if none is given. A real app should let the user choose, or rotate by group; blindly using the first one keeps hitting the same profile in a multi-account setup
  2. Make sure it is open — skip if it already is; opening again runs a full startup and wastes several seconds
  3. Build the search URL directly Open a tab instead of going to the home page and clicking the search box — never express with a click what a URL can express; one step fewer is one thing fewer that can break
  4. Poll until the products render (see below)
  5. evaluate Scrape the data inside the page
  6. Save a screenshot as evidence
  7. one transaction Bulk-write into the database
Don't wait for the page with a fixed sleep

sleep(5000): on a fast connection you wait for nothing, on a slow one it isn't enough — wrong at both ends. The right approach is to poll until it appears with an upper bound:

let found = 0;
for (let i = 0; i < 20; i++) {
  found = await automation.evaluate(pid,
    `document.querySelectorAll('[class*="offer"]').length`);
  if (found > 0) break;
  await new Promise(r => setTimeout(r, 700));
}
if (!found) return { ok:false, error:'no products found on the page — it may need a login, be rate-limited, or have been redesigned' };

This is the step in automation that is most often written wrong. A script with a hard-coded wait will break on someone else's machine, and when it does it looks like “the site is broken”.

Keep selectors loose, and say so clearly when nothing matches

E-commerce pages are redesigned constantly, and scraping code pinned to one class does not survive a month. Use fuzzy matching such as [class*="offer-item"] with several fallbacks.

More important: do not return an empty array when nothing matches. An empty array is read upstream as “no products”, everything proceeds normally, and you end up with an empty report — while the truth was that the page required a login. Report explicitly: “nothing found, here are the likely reasons”.

Close tabs in a finally block
try {
  tabId = (await automation.newTab(pid, url)).id;
  // …any early return in between…
} finally {
  if (tabId) await automation.closeTab(pid, tabId);
}

Every tab is a renderer process. Put it at the end of the try block and any branch that returns early skips it — round after round, memory climbs until the client stalls.

Code inside evaluate runs in the page

const items = await automation.evaluate(pid, `
  (function () {
    var out = [];
    document.querySelectorAll('[class*="offer-item"]').forEach(function (c) {
      var t = c.querySelector('[class*="title"]');
      if (t) out.push({ title: t.innerText.trim() });
    });
    return out;      // ★ must be JSON-serialisable
  })()`);
The return value must be JSON-serialisable

Returning a DOM node gives you an empty object {} — no error, just nothing. This is the pitfall beginners hit most often.

Use one transaction for bulk writes

db.tx(() => {
  for (const it of items) db.exec('INSERT INTO offers(...) VALUES(?,?,?)', [...]);
});

Ten rows in ten commits means ten disk syncs — an order of magnitude slower.

Carry “which step it got to” back on failure

In the example, every step is recorded into steps and pushed to the UI live. When something goes wrong the user sees “stuck at waiting for products to load” instead of a flat “it failed” — with the former they can tell for themselves whether it needs a login or the network is slow.

The example only performs read-only actions

Open a window, search, read the list, screenshot —it does not add to cart and does not place orders. Those have real consequences, and example code should not do them on your behalf. If you do, get the user's confirmation first and record what was done.

File picker files:pick

When to use it Let the user pick a file to read, or save results where they choose.
When not to use it It opens a system dialog and blocks. Do not put it inside a bulk loop — one dialog on the third item and the user assumes the program has hung.
Example app: main.js, section 7
const f = await files.pickOpen({
  title: 'Pick a CSV',
  filters: [{ name: 'Spreadsheet', extensions: ['csv', 'xlsx'] }],
});
if (!f) return;                       // the user cancelled — not an error
f.name; f.size; f.path; f.bytes;      // bytes is a Buffer

await files.pickSave({ title: 'Export', defaultPath: 'out.csv' }, buffer);
There is no readFile(path)

— that would give the app arbitrary read access. You only get the file the user actually picked.

These two are modal: they do not return until the user chooses

So each of them needs pickOpen / pickSave its own dedicated IPC endpoint that does only this one thing, with its own button in the UI.

Do not put it in the same endpoint, the same loop, or the same “run everything” action as anything else — everything else in that endpoint is blocked behind the dialog, the UI sits at “working…” forever, and the log is clean with no exception and no timeout: the hardest kind to diagnose. Warning the user in the UI that “a dialog opens here” does not help — what is blocked is the code, not the user.

The spec check (devcheck) rejects mixing a dialog with several other capabilities in one endpoint.

Three constraints when writing the UI

Example app: the three files under ui/

0. Use the platform components first; don't write styles from scratch

The platform provides a set of component classes prefixed with gs- -prefixed component classes; just use the class names — you do not write those styles yourself. They follow the client theme and match the host UI naturally.

gs-* Use them; do not modify them

Do not override or redefine gs- classes — that would affect the host UI and other apps as well. If you need something different, use your own prefix to write a new class (see the next section, “Every CSS selector needs the prefix”).

Example: a typical app UI
Several words allowed, separated by spaces
ProductPriceStatus
Wireless earbuds A1¥129.00 Stored
Noise-cancelling headphones B2¥299.00 Skipped
Sports earbuds C3 Price lookup failed
General notes use gs-note-info
Things to watch use gs-note-warn
Errors use gs-note-bad

Class reference

Buttons

ClassPurpose
gs-btnButton (default)
gs-btn-ghostGhost button (borderless, secondary action)
gs-btn-priPrimary button (blue; there should be only one per screen)
gs-btn-smSmall; stacks with the ones above

Layout

ClassPurpose
gs-cardCard container (white, rounded, hairline border)
gs-colVertical stack
gs-padAdds padding inside a card
gs-rowHorizontal, wraps automatically
gs-spacerSpacer that pushes what follows to the right
gs-toolbarTop toolbar (horizontal + bottom margin)

Data display

ClassPurpose
gs-emptyEmpty state (the “no data yet” block)
gs-itemOne row of a list
gs-listList container

Forms

ClassPurpose
gs-fieldOne form group (label + control)
gs-inpSingle-line input
gs-labelForm label
gs-selSelect
gs-taTextarea
gs-tableData table
gs-tagTags / badges
gs-tag-badRed tag (failure)
gs-tag-grayGrey tag (disabled / neutral)
gs-tag-okGreen tag (success)

Marks and notices

ClassPurpose
gs-hintSmall caption under a control
gs-monoMonospace (IDs, paths, amounts)
gs-noteNotice block (colour set by the three below)
gs-note-badRed · something went wrong
gs-note-infoBlue · general note
gs-note-warnOrange · pay attention
Is this component set enough?

It covers the shapes business UIs usually need: toolbars, cards, forms, tables, lists, tags, notices. For anything else (charts, calendars, drag-and-drop), write your own prefixed classes — the component library does not cover those, because covering them would mean maintaining a general-purpose widget set forever, while every app needs something slightly different.

1. The HTML is a fragment, not a complete document

Do not write <html> / <head> / <body>. It is mounted straight into the host UI.

2. Every CSS selector needs the prefix

One rule only: in ui/style.css, every rule and every comma-separated part of it must start with .your-prefix-.

.abc-wrap { }                ✅
.abc-wrap .title { }         ✅ descendant selector — already fenced inside its own subtree by the prefix
.abc-btn:hover { }           ✅ pseudo-class
@media (max-width:600px){ .abc-wrap { } }   ✅ still required inside media queries
@keyframes abc-fade { }      ✅ prefix animation names too (avoids clashing with others)

.wrap { }                    ❌ bare class
button { }                   ❌ every button in the client would change
#panel { }                   ❌ ids are global
* { margin:0 }               ❌ one reset line can wreck the entire client UI
.abc-a, .navbar { }          ❌ the part after the comma needs it too
.gs-btn { background:red }   ❌ platform components may be used, not modified

Third-party frameworks: three are already bundled

If you build with AI, it asks which one first

After you create an app project and describe what you want, the AI's first turn only asks, it does not write: it offers four clickable options (platform components / Bootstrap 5 / Milligram / Daft), and only starts generating once you pick.

Why it asks instead of choosing for you: the framework is a decision that cannot be changed afterwards — the whole UI is built on it, and swapping it means rewriting the entire ui/. One click to avoid one rewrite is worth it.

If you already know, just say so (“build a … with Bootstrap”) and it will not ask.

One line in manifest.json enables it, and the app carries no framework files:

{
  "key": "abc-demo",
  "ui": { "framework": "bootstrap5" },
  ...
}
frameworkNotesSize
bootstrap5Bootstrap 5.3.3, Includes JS components (modal / dropdown / collapse / tabs / carousel / tooltip)CSS 258KB + JS 81KB
milligramMinimal, and class-free — write semantic HTML and you get styling. Pure CSS22KB
daftClass-free, modern look (close to the feel of shadcn/ui). Pure CSS86KB
Your HTML contains nothing platform-specific

The framework styles hang under a scoping container, and the platform wraps that container for you — you just write a normal page:

<!-- once bootstrap5 is declared, just write this -->
<div class="card"><div class="card-body">
  <button class="btn btn-primary" data-bs-toggle="modal" data-bs-target="#m1">Open</button>
</div></div>

<!-- class-free frameworks (milligram / daft) need no class at all -->
<h2>Heading</h2>
<form><input type="text"><button>Submit</button></form>
<table>…</table>

This means an existing page can be moved over as-is, and it also means you do not have to convert it back when moving away.

The style prefix is derived from app_key; if it collides, set your own

Prefix = the first letter of each segment: abc-price-helperaph. Collisions are possible within one developer (abc-batch-run and abc-bulk-run both give abr).

A collision does not cross-contaminate — the client loads only the current app's styles and removes them on exit. But it is easy to confuse when reading code, so declare it in the manifest to tell them apart:

{ "key": "abc-bulk-run", "uiPrefix": "abrun", … }

Once specified, both the spec check and AI generation follow the prefix you set.

If you declare a framework, use its classes throughout; do not mix them with gs-*

Mixing gives you two grid systems, two spacing scales and two control heights — it looks like it works, and afterwards nobody can change anything consistently. We tested a build where an input was written as class="gs-inp w-100": height came from gs-inp and width from Bootstrap, and changing either side only moved half of it.

AI generation follows the same rule: if a framework is declared, the prompt no longer contains the gs-* list.

Third-party wins inside the app area

The client's own styles all step aside inside that container and never override a same-named framework component. So classes such as .btn, .card get the framework's styling, not ours.

To use a framework other than the bundled three

You can, but you must scope-wrap it first. Importing it as-is is rejected by the spec check — the first thing frameworks like this do is reset the global: *{box-sizing:border-box}, body{margin:0}, h1~h6 font size… The app UI and the client share one document, so those rules hit host elements and change the whole client's typography — for everyone who installed the app.

How: prefix every rule of the framework with a container class of your own, then wrap the HTML in that container.

/* ❌ as-is — the spec check rejects this */
*,::before,::after { box-sizing: border-box }
body { margin: 0 }
.btn-primary { ... }

/* ✅ prefix every rule with .abc-bs */
.abc-bs *, .abc-bs ::before, .abc-bs ::after { box-sizing: border-box }
.abc-bs body { margin: 0 }        /* matches nothing — harmless */
.abc-bs .btn-primary { ... }
<!-- wrap one layer in the HTML; inside, use the framework classes as usual -->
<div class="abc-bs">
  <div class="card"><div class="card-body">
    <button class="btn btn-primary">OK</button>
  </div></div>
</div>
A framework you wrap yourself is still bound by the app area

Any overlay, drawer or popup you bring in, as long as it is position:fixed, still stops at the app area boundary and cannot cover the client — the platform enforces this on the app container, regardless of which framework you use.

Wrapping can be done by a tool; you do not edit thousands of lines by hand:

# Sass: one line
.abc-bs { @import "bootstrap/scss/bootstrap"; }

# PostCSS
postcss bootstrap.css --use postcss-prefix-selector \
  --postcss-prefix-selector.prefix ".abc-bs" -o scoped.css
Three prerequisites
Dialogs and overlays cover the app area only, never the client

For position:fixed elements, the app area is the containing block, so anything “full-screen” inside the app (a Bootstrap modal, an overlay you wrote yourself) reaches at most the app boundary — the left nav and top bar stay clickable.

This is not cosmetic: if an overlay ever fails to close (a failed save, a script error, a click on something with no handler bound) and the user cannot even reach the sidebar, they have no way out but to quit the entire client. So the platform guarantees it and you need do nothing.

Incidentally: Bootstrap's two backdrop layers attached to body are hidden by the platform, and the grey backing is provided by .modal instead — same look, controlled extent.

First consider whether it's worth it

Wrapping is a one-off cost, but a framework's styling and the client's own visual language are two different systems — users will plainly see “this part was bolted on”. If all you need are buttons, tables and forms, the gs- components above follow the client theme, need no wrapping and cost no size. A framework is genuinely warranted when it has something gs-* does not (grid systems, modals, date pickers and the like).

The prefix is derived from the key (first letter of each segment). A CSS identifier cannot start with a digit, so one starting with a digit automatically gets a a: 8d7dk-examplea8e prepended. The spec check tells you which prefix to use.

Why not isolate with an iframe

An iframe gives up the host's CSS variables (theme, colours) and needs an extra messaging bridge. Having chosen one document, the prefix convention is what keeps things apart — which is why the spec check verifies it rule by rule.

3. app.js must be an IIFE

(function () {
  'use strict';
  // your code
})();

Without a wrapper your let out = ... lands on window and collides with the host or another app — and a collision shows up as “another app's variable inexplicably changed”, the hardest kind to track down.

Electron does not support window.prompt

alert and confirm work; only prompt does not — calling it throws prompt() is not supported outright, and the button appears to do nothing at all. If you need input, build your own dialog.

Developer ID and app_key

Before publishing your first app, register a developer ID under Apps → 🛠 My apps (the API field name is devHandle). It becomes the prefix of all your apps — the same thing as npm's @scope, a Docker username, or a VS Code publisher.

developer ID you registered    smartmob
second half of the app key     sourcing        ← lowercase letters / digits / hyphens only
                     ↓
resulting app_key              smartmob-sourcing

Why a prefix is required

app_key is not just a name; it is simultaneously three things:

IdentityLooks like
Package path in the cloudapps/smartmob-sourcing/0.1.0/…
App directory name on the machinedev-apps/smartmob-sourcing/
IPC routing keyHow the UI finds your main.js

So two developers who each create an app called report would overwrite each other, and silently — B's client installs A's code with no error at all. The prefix exists to prevent exactly this.

The developer ID cannot be changed once you have published an app

Because users who already installed your app find it by app_key. Changing the developer ID re-identifies every published app, and they can no longer find the one they installed.
It can be changed freely until you publish anything, so there is no need to fear getting it wrong — the moment that really matters is the first publish, not registration.

What it does not do today

The developer ID is a technical namespace, not a display name. The “developed by XX” on an app card comes from manifest.author → account display name → email prefix, and has nothing to do with the developer ID. Ordinary users see the app name and icon; app_key never appears.

Naming suggestions

SuggestedAvoid
Developer IDsmartmob zhiqu-tech a1 (too short to be recognisable), my-company-tech-dept (too long — every key has to carry it)
App name partsourcing erp-sync test demo app1 (in three months you will not recognise it either)

Rules: 3–20 characters, starts with a lowercase letter, lowercase letters, digits and hyphens only; no consecutive hyphens and no trailing hyphen. official gathersurf admin Reserved words like these cannot be registered — they would be used to impersonate the platform.

Platform-operated apps carry no prefix

On the Apps page you will see prefix-less keys such as purchase-order — those are published by us. Prefix or no prefix is the most direct way to tell an official app from a third-party one — which is also why the reserved-word list exists.

Naming variables and endpoints

Inside your app, write it however you like — we do not check. But the set below is what the platform itself uses, and following it means your code, the docs, the example project and whatever AI writes for you later all look like one codebase.

The rules in one line

WhereStyleExample
JS variables / functions / object keyscamelCasegoodsList fetchPrice()
JS classes / constructorsPascalCasePriceTracker
Constants (genuinely constant)UPPER_SNAKEMAX_RETRY
IPC channel namesmodule:actiongoods:list collect:start
CSS classprefix-name.x8t-card (the prefix is generated by the platform — do not invent your own)
File nameskebab-caseprice-parser.js

Why IPC channel names are module:action

goods:list      goods:star     goods:remove
collect:start   collect:stop   collect:state
win:create      win:remove     win:proxies

The benefit is that a module prefix shows you every action of one feature at a glance, whereas names like listGoods / starGoods / removeGoods scatter through a long list. The thirty-odd handlers in the example project main.js are ordered this way — skim it and the intent is obvious.

A name should say what it is, not how short it is

Don't writeWriteWhy
d tmp data2goods draftRow mergedGoodsIn three months you would have to re-read it to know what it is
flag statusisRunning collectStateBooleans start with is/has/can so you can see at a glance to treat it as true/false
timecreatedAt durationMsCarry the unit. durationMs, createdAtMs, priceCents — with the unit in the name there is no convention to remember
pricepriceCentsAmounts use integer cents, never floats
getUser() (it actually writes to the database)fetchUser() / saveUser()get Suggests no side effects
Mixed timestamp units will bite you

We were bitten by this ourselves: the backend wrote milliseconds and the client wrote seconds into the same column. Everything looked fine until one expiresAt > now() compared seconds against milliseconds — permanently false, authorisation instantly void, and no error of any kind.
So: use one unit across the entire app, and put the unit in the name (createdAtMs, durationMs).
Conventions get forgotten; names do not.

Database tables and columns

The conventions for host.db when creating tables. This is not fastidiousness — SQL has its own rules, and writing it the JS way gets you bitten by the language itself.

Why not camelCase in the database

SQL case-folds unquoted identifiers. You write createdAt, and what gets stored may be createdat; to preserve the case you must quote it everywhere, and one missed spot is a runtime error. So:

database column   snake_case     created_at_ms, goods_id, price_cents
    ↓ converted once, at the moment it is read out
JS code           camelCase      createdAt,  goodsId,  priceCents

Keep the conversion point in exactly one place — the object your query function returns. Outside that function the whole app is camelCase.

// ✓ hand-written projection — one conversion point
function listGoods() {
  // host.db is synchronous — do not add await
  const rows = host.db.query(
    'SELECT id, title, price_cents, created_at_ms FROM goods ORDER BY id DESC LIMIT 200');
  return rows.map(r => ({
    id: r.id, title: r.title,
    priceCents: r.price_cents,      // ← the conversion happens only here
    createdAtMs: r.created_at_ms,
  }));
}

// ✗ returning database rows directly — underscores then leak throughout the app
function listGoods() {
  return host.db.query('SELECT * FROM goods');   // and there is no LIMIT
}
Why not write a helper that converts automatically

SELECT * plus automatic camelCasing looks convenient, but it also ships columns you never meant the UI to see (password hashes, third-party tokens, internal notes). A hand-written projection is essentially a allowlist — a few more lines, in exchange for “the UI can never get a field it shouldn't”.

Timestamps: your database uses milliseconds, our cloud uses seconds

You may notice the inconsistency, so to be clear —these are not two standards but one principle: Do zero conversion on the writing side.

DatabaseWho writesUnitColumn name
Your host.dbYour JS codeMillisecondscreated_at_ms
Our cloud PostgreSQLPython serviceSecondscreated_at

Date.now() gives milliseconds; time.time() gives seconds. Each stores directly, so there is no conversion and therefore no chance of converting wrongly.

And the two databases never meet — an app cannot obtain our cloud timestamps (profiles.list() returns only id / profile number / name / group / platform), so there is no risk of two units landing in one column. That was always the fatal part, not which unit you pick.

Column conventions

RuleNotes
Table names are pluralgoods suppliers price_history
The primary key is simply idForeign keys are <table-singular>_id: supplier_id
Time columns always end in _at_mscreated_at_ms updated_at_ms. Not plain at, and not last_login
Store timestamps as Date.now()milliseconds, via INTEGERThe writing side is JS and Date.now() is milliseconds already —no conversion means no mis-conversion
Amounts are integer centsprice_cents INTEGER. Money in floats will eventually fail to reconcile
Booleans start with is_/has_is_starred has_stock (stored as 0/1 in SQLite)
No abbreviationsOnly industry-standard ones like id / url / ip / sku may be short. pw (plaintext or hash?) and grp, desc are both out
Sensitive columns should declare themselves in the namepassword_hash rather than pw — the name itself is the warning for whoever comes next

Create tables through migrate(), not a direct exec

Put both table creation and column additions in migrate(). It keeps a ledger by version number: a version that has run is skipped entirely on the next start, so you can call it unconditionally in register() without checking whether the table exists.

host.db.migrate([
  {
    v: 1,
    name: 'create_goods',
    up: [`
      CREATE TABLE IF NOT EXISTS goods (
        id            INTEGER PRIMARY KEY AUTOINCREMENT,
        keyword       TEXT    NOT NULL,
        title         TEXT    NOT NULL,
        shop_name     TEXT,
        price_cents   INTEGER NOT NULL DEFAULT 0,   -- integer cents, never floats
        is_starred    INTEGER NOT NULL DEFAULT 0,   -- boolean: 0/1
        source_url    TEXT,
        created_at_ms INTEGER NOT NULL,             -- Date.now(), milliseconds
        updated_at_ms INTEGER NOT NULL
      )`,
      // index the columns you query most — past a few thousand rows, an unindexed fuzzy search visibly stalls
      `CREATE INDEX IF NOT EXISTS idx_goods_keyword ON goods(keyword)`,
    ],
  },
  // to add a column later, add a new version; do not edit the contents of v:1
  { v: 2, name: 'goods_add_note', up: [`ALTER TABLE goods ADD COLUMN note TEXT`] },
]);

returns { from, to } — the two values being equal means nothing ran this time.

Three hard rules

Use exec(sql, params) for insert, update and delete; query(sql, params) for queries; and wrap a batch of writes in tx(fn).

A table nobody writes to is the hardest kind of bug to notice

The CREATE ran, the code raises nothing, the UI looks fine — only that number stays at 0. Write one test row and read it back the moment you create the table; don't discover the write path was never wired up after the whole feature is built.

This page is for external programs — with Python / Node / Go / curl or any RPA tool, from your own process, to drive GatherSurf. It is a completely different path from developing an app inside the client.
Open the port in the client first: Settings → Local Automation API; it only listens on 127.0.0.1:48090 once enabled. It is off by default — leave it off and every request fails to connect, which looks exactly like “the API is broken”.

External API: what it is and how to enable it

Besides developing an app inside the client, there is a second path: drive GatherSurf from your own language, in your own process. The client runs an HTTP service on the local machine, and you can use Python, Node, Go, curl — anything.

In-app API host.*External API /gs/v1
Who it suitsThose who want to ship a tool to other peopleThose with their own stack who just want to drive the browser
DeliverableA publishable app cardYour own script
UsersPeople who installed your app, no technical knowledge neededJust you
How you call ithost.profiles.list()GET /gs/v1/profiles
AuthenticationManifest permission declarationsX-GS-Token
PrerequisiteThe user installed your appThe client is running + enabled in Settings

How to enable

  1. Client → Settings →Local Automation API→ turn on
  2. The same page shows your token (the account-level API token)
  3. The address is fixed at http://127.0.0.1:48090 and listens on the local machine only
The token is equivalent to your account

It can create, delete and launch profiles and read cookies. Do not put it in a file that gets committed — use an environment variable.
Except for /health, every endpoint requires the token — this keeps other local processes and malicious web pages (localhost fetch, DNS rebinding) from driving your browser.

The local API can never do more than the client itself

When the client is not logged in the token is empty and every request is rejected (not allowed through). Creating profiles hits the same cloud quota gate — over the limit returns 403 rather than 502, with an error message that states the reason.

Up and running in 30 seconds

Don’t want to write a script?

There’s a ready-made test console you can download. Click through every endpoint on this page and see what comes back before you write anything — see the next section, Test console: download and usage.

Liveness first (this one needs no token)

curl http://127.0.0.1:48090/gs/v1/health

Python

import os, requests

BASE  = "http://127.0.0.1:48090/gs/v1"
TOKEN = os.environ["GS_TOKEN"]          # ★ never hard-code it
H = {"X-GS-Token": TOKEN}

# 1. create a profile
r = requests.post(f"{BASE}/profiles", headers=H, json={
    "name": "test profile",
    "group": "Default",
    "proxyLine": "socks5://user:[email protected]:1080",   # optional
}).json()
pid = r["data"]["id"]

# 2. open the window — returns a CDP address for playwright/puppeteer to take over
r = requests.post(f"{BASE}/profiles/{pid}/launch", headers=H, json={}).json()
cdp = r["data"]["automation"]["cdpWs"]
print("CDP:", cdp)

# 3. attach with playwright
from playwright.sync_api import sync_playwright
with sync_playwright() as pw:
    browser = pw.chromium.connect_over_cdp(cdp)
    page = browser.contexts[0].new_page()
    page.goto("https://httpbin.org/ip")
    print(page.content()[:200])

# 4. close it
requests.post(f"{BASE}/profiles/{pid}/shutdown", headers=H, json={})

Node

const BASE = 'http://127.0.0.1:48090/gs/v1';
const H = { 'X-GS-Token': process.env.GS_TOKEN, 'Content-Type': 'application/json' };
const api = async (method, path, body) => {
  const r = await fetch(BASE + path, { method, headers: H,
    body: body ? JSON.stringify(body) : undefined });
  const j = await r.json();
  if (!j.ok) throw new Error(`${path} failed: ${j.error?.message || r.status}`);
  return j.data;
};

const { id } = await api('POST', '/profiles', { name: 'test profile' });
const { automation } = await api('POST', `/profiles/${id}/launch`, {});
console.log('CDP:', automation.cdpWs);

// take over with puppeteer
const puppeteer = require('puppeteer-core');
const b = await puppeteer.connect({ browserWSEndpoint: automation.cdpWs });
const p = await b.newPage();
await p.goto('https://httpbin.org/ip');
console.log((await p.content()).slice(0, 200));

await api('POST', `/profiles/${id}/shutdown`, {});
One response format throughout
success:  { "ok": true,  "data": { … } }
failure:  { "ok": false, "error": { "code": "forbidden", "message": "plain language" } }

Status codes are real: 401 not logged in / bad token, 403 quota exhausted or no permission, 404 not found, 409 state conflict (e.g. changing the fingerprint while the profile is open), 502 cloud error. A quota-full condition is never collapsed into a 502 — that would leave you unable to tell whether to upgrade the plan or retry.

Test console: download and usage

The Local API test console is an external tool — zero-dependency Node, with a UI, full source included. It is written from a third-party developer’s point of view: it uses only the public endpoints on this page and relies on no internal conventions. Two uses: run everything once to see what works, and a calling example you can copy straight out.

What it can do

CapabilityNotes
Endpoint catalog Every endpoint on this page is in there, each with a plain-language note and a default request body
Three risk tiers read read-only · write changes data · danger irreversible, or interrupts the client. “Run all” never touches the danger tier — a “test tool” that deletes the user’s profiles is the easiest and least forgivable mistake this kind of tool can make
Two gates on the danger tier Hard delete, emptying the trash, and restarting/quitting the client can only be triggered one at a time: the page asks for confirmation, and the process checks again before acting
Path parameters resolved automatically {id} is taken from the first item the matching list endpoint returns; if there is none, the call is skipped with a reason given — rather than taking undefined and building /profiles/undefined out of it to hit a 404 — that kind of failure reads as “the endpoint is broken”
End-to-end script e2e.js skips the UI and runs real scenarios end to end: create a profile → launch → export cookies → shut down → delete. Where a 403/409 is expected, it asserts that exactly that code came back — otherwise “fixed” and “not fixed” look identical in the report

Download

https://api.gathersurf.com/download/tool/gs-api-test  ·  Version and checksum: /manifest

This URL always points at the newest release (it 302-redirects to the nearest download node), so there is no version number to remember. After downloading, check that sha256 matches the manifest — this tool asks you to paste your token into it, which makes those 5 seconds worth spending:

shasum -a 256 gs-api-test.zip                # macOS / Linux
certutil -hashfile gs-api-test.zip SHA256    # Windows

How to run it

Requires Node 18 or newer (check with node -v). Unzip, then in that directory:

node gs-api-test.js      # starts the UI; open http://127.0.0.1:48099
node e2e.js              # or skip the UI and run the end-to-end scenarios

Once the UI is open, paste your token in and start clicking. ★ the token lives only in this Node process’s memory — never written to a file, never logged. Kill the process and it is gone, so you paste it again every time you start it.

It is a local process, not a web page

The local API sends no CORS headers at all, so a browser page cannot read the responses — the test console has to be a Node process running on your machine, and its UI is a page it serves itself.
That CORS door is exactly what stops a malicious web page (localhost fetch, DNS rebinding) from driving your browser. Any “work around CORS” trick tears that door off.

The source is meant to be copied

How to send the token, how to read the response envelope, how to tell “the endpoint is broken” from “403, quota full”, how to confirm before deleting — all of it is spelled out in the source, which is far faster than writing it from scratch against the docs.

Endpoint reference

Field names are camelCase throughout

Historically two styles were mixed (proxy_id / proxyLine coexisting); today both are accepted, but the docs teach camelCase only. Old scripts need no changes; please use camelCase in new code:
proxyId · kernelVersion · keepFingerprint · intervalMs · timeoutMs · basedOnIP · keepExtensions

The service itself

EndpointNotes
GET /healthLiveness. The only one that needs no token
GET /accountCurrent account + quota (how many more profiles you can create)
GET /statuspid / uptime / version / how many profiles are open
GET /settings · PATCH /settingsConfigurable kernelPath / headful / kernelVersion
POST /server/restartRestarts the client; the same port is back in 3–8 seconds
POST /server/shutdownQuits the client
POST /server/kill-orphansCleans up kernel processes that did not exit cleanly

Profiles

EndpointParameters / notes
GET /profilesAll profiles. Each carries a lastOpenedAt(second-level timestamp (never opened is null) and locked (true = over quota, cannot be opened); the envelope also carries a quota summary
POST /profilesname group tags remark archetype (default win11-desktop) seed proxyId|proxyLine basedOnIP kernelVersion platforms overrides
GET /profiles/{id}A single
PATCH /profiles/{id}Incremental update: only the fields you send are changed. proxyLine:"" = clear the proxy
DELETE /profiles/{id}Really deletes it (the window is closed first)
POST /profiles/{id}/launchOpens the window. Returns automation.cdpWs / debugPort.
Profiles over quota cannot be opened (only the newest N can — see GET /profiles in locked).
This case returns 403 + code as QUOTA_EXCEEDED or PLAN_EXPIRED (since 2026-08-05; it used to wrongly return 500).
Do not retry on 403 — ten thousand retries change nothing; 5xx is what you may retry. Decide on code,and do not match on the message text (one word changed and your check stops working).
POST /profiles/batch/launch The failing entry in it now also carries error and code.
POST /profiles/{id}/shutdownCloses the window. Graceful close: tabs are saved to disk and restored the next time the profile opens
POST /profiles/launch-newCreate and open in one step
POST /profiles/{id}/copycount(≤50) keepFingerprint. Uses a fresh fingerprint by default. Does not copy passwords or cookies
POST /profiles/{id}/clear-cachecookies (cookies are cleared too) keepExtensions (default true)
GET /sessionsCurrently open profiles plus each one's CDP address

Fingerprints

EndpointNotes
POST /fingerprint/previewGenerates only, without creating a profile, so it uses no quota. Look first, then decide
GET /profiles/{id}/exportExports a fingerprint bundle (seed+archetype+overrides)
POST /profiles/importRestores the same fingerprint from a bundle, across accounts
POST /profiles/{id}/refresh-fingerprintAssigns a new fingerprint. The profile must be closed first, otherwise 409.
An alias /randomize — the same endpoint; old scripts may use this name
Fingerprints are generated deterministically

(seed, archetype, overrides) Three inputs determine a fingerprint. That is why “export here, import into another account” restores exactly the same fingerprint, without transferring a large blob of fingerprint data.

Cookie

EndpointNotes
GET /profiles/{id}/cookiesExport. Works with the profile closed — it is opened headless in the background, read, and closed again. ★ This requires the profile to have been opened at least once: a profile that has never been opened has no local data folder yet, so you get 409 no_local_data (not a 5xx — do not retry)
PUT /profiles/{id}/cookiesImport. Requires the profile to be running, otherwise 409. Injected over CDP and persisted to disk

Batch

EndpointParameters
POST /profiles/batch/launchids intervalMs (waits a moment between each) kernelVersion
POST /profiles/batch/shutdownids or all:true
PATCH /profiles/batchids + group/tags/remark, one of
POST /profiles/batch/deleteids

Batch endpoints always return per-item results: { total, succeeded, results:[{id, ok, error?}] }—— a partial failure does not fail the batch, and you know exactly which ones did not succeed.

Proxies

EndpointNotes
GET /proxies · GET /proxies/{id}List / single
POST /proxiesline (socks5://user:pass@host:port or host:port:user:pass) tags
PATCH /proxies/{id}Update line/type/tags. Changing the address automatically resets the check status
DELETE /proxies/{id}Delete
POST /proxies/checkCheck an unsaved string: line timeoutMs. Verify before creating profiles
POST /proxies/{id}/checkCheck a stored one; the result is written back (exit IP / country / status)
GET /proxy-tagsAggregates every proxy tag with its count
PATCH /proxy-tags/{name}Rename, applied to every proxy
DELETE /proxy-tags/{name}Removes it from every proxy

Groups / tags / recycle bin

EndpointNotes
GET/POST /groups · PATCH/DELETE /groups/{id}Group CRUD. ★ GET includes the default group(builtin:true,id __ungrouped__, whose name is in English Ungrouped) and the unregistered group(orphan:true,id __orphan__*) — these two return 400 on rename or delete, so check this before iterating filter(g => !g.builtin && !g.orphan)
GET /tagsAggregates every tag with the number of profiles carrying it
PATCH /tags/{name}Rename, applied to every profile
DELETE /tags/{name}Removes this tag from every profile
POST /profiles/{id}/trash · /restoreSoft delete / restore
GET /trash · POST /trash/emptyRecycle bin list / empty (really deletes)
/trash and DELETE are two different things

POST /profiles/{id}/trash is soft delete (the cloud profile remains and is purged automatically after 30 days); DELETE /profiles/{id} is a immediately and for real. The names are easy to confuse — do not mix them up in a script.

Kernels

EndpointNotes
GET /kernelsWhich versions exist, whether installed, whether an update is due
GET /kernels/{v}/statusWhether this version is the latest
POST /kernels/{v}/upgradeDownload / self-heal to the latest build
POST /kernels/{v}/defaultSet as default
DELETE /kernels/{v}Remove this version
POST /profiles/{id}/kernelPin a given profile to a kernel version

Common recipes

Auto-check a proxy pool and tag the bad ones

proxies = requests.get(f"{BASE}/proxies", headers=H).json()["data"]["proxies"]
for px in proxies:
    r = requests.post(f"{BASE}/proxies/{px['id']}/check", headers=H,
                      json={"timeoutMs": 8000}).json()["data"]
    if not r["alive"]:
        # tag it for a human to look at
        requests.patch(f"{BASE}/proxies/{px['id']}", headers=H,
                       json={"tags": px.get("tags", []) + ["dead"]})
    print(px["id"], "✓" if r["alive"] else "✗", r.get("ip", r.get("error")))

One proxy per profile, created in bulk

lines = open("proxies.txt").read().split()
for i, line in enumerate(lines, 1):
    chk = requests.post(f"{BASE}/proxies/check", headers=H, json={"line": line}).json()["data"]
    if not chk["alive"]:
        print(f"skipping line {i}: {chk['error']}"); continue   # ★ verify first, don't create a pile of dead profiles
    requests.post(f"{BASE}/profiles", headers=H, json={
        "name": f"p-{i}-{chk['country']}", "proxyLine": line,
        "basedOnIP": True,     # align language/timezone to the exit IP, avoiding geographic contradictions
    })

Copy one profile's fingerprint into another account

# export from account A
fp = requests.get(f"{BASE}/profiles/{pid}/export", headers=H).json()["data"]
json.dump(fp, open("fp.json", "w"))

# import into account B (different token) — restores the same fingerprint
fp = json.load(open("fp.json"))
requests.post(f"{BASE}/profiles/import", headers=H2, json={"fingerprint": fp, "name": "restored"})

Open profiles in bulk to run a task, with bounded concurrency

ids = [p["id"] for p in profiles[:10]]
r = requests.post(f"{BASE}/profiles/batch/launch", headers=H,
                  json={"ids": ids, "intervalMs": 2000}).json()["data"]
print(f"{r['succeeded']}/{r['total']} launched")
for x in r["results"]:
    if not x["ok"]: print("  failed:", x["id"], x.get("error"))
    else:           print("  CDP:", x["automation"]["cdpWs"])

# … run your task …

requests.post(f"{BASE}/profiles/batch/shutdown", headers=H, json={"ids": ids})
Three easy traps

Spec check

⚙ Settings → Spec check. A failing check blocks packaging — not to obstruct you, but because these same problems get rejected after upload, when the reason was knowable locally all along.

What is checkedWhy
manifest complete, version is x.y.zA non-conforming version makes update decisions impossible
Directory name matches manifest.keyA mismatch installs it in the wrong place
Every permission is on the allowlistOne wrong letter and the capability is silently unavailable
If you set http.allow, every entry must be a valid domain or IP.If the target is fixed, declare it truthfully; runtime enforcement is not strict for now.
No node_modulesApp packages carry no dependencies
README.md presentWhoever installs it needs to know what it is
No require('electron') / require('fs')to bypass the sandbox
No bare ipcMain.handleThe platform has no idea that is your endpoint
Exports registerWithout it the app cannot load
UI is a fragment, CSS is prefixed, app.js is an IIFESee the previous section

The spec check covers only what a machine can decide. Whether the permissions are minimal and the error handling adequate needs a human — passing the check does not guarantee approval.

Beyond the static check: the API self-check console

The spec check covers only what a machine can decide. An app that passes all 25 items can still fail to call a single API — a permission not requested, the account not signed in, the plan not covering it, one wrong letter in a method name. Those surface only at runtime.

The client ships an app called “API self-check console” (under Apps → 🛠 My apps; its key is gs-apicheck). It runs every host API the platform exposes right there and tells you which ones work, how long they took and what they return. When your app “does nothing when clicked”, run this first — if it fails too, the problem is not in your code. Its main.js is at the same time a calling example you can copy.

It splits operations into three tiers, and the irreversible tier never enters a batch run (really deleting profiles, emptying the recycle bin). Write operations such as creating profiles and opening windows require an explicit opt-in, and dialogs and AI can only be triggered by hand. Use the same split in your own test tooling — a “test tool” that deletes the user’s profiles is the mistake this class of tool makes most easily and can least be forgiven for.

Three ways to publish

Who can use itCode uploadedReview requiredWhat others download
💻 LocalThis device onlyNoNo
👥 TeamMembers you authoriseUploaded to the cloudNo, effective immediatelyEncrypted package
🌐 Public listingAll GatherSurf usersUploaded to the cloudYes, reviewed by a personEncrypted package

The code you ship is encrypted

As soon as it goes through cloud distribution (team / public listing), the package the user installs locally is .js .html .css .json .md .txt, on disk, ciphertext. A member who opens the app folder sees binary, and copying it away does not make it run.

StageFormWhy
You package and uploadPlaintextThe server has to open it to run the validators (path traversal / reverse dependencies / permission overreach), and a public listing is also read by a human
Stored on the serverThe plaintext originalNeeded for re-review, support investigations, and for you to look back at
Published / review passedEncrypted onceOne random key per version, saved as the distribution artefact
User downloadsCiphertextDecrypted in memory only at runtime, never written to disk
Development and debugging are entirely unaffected

Encryption happens only at the moment of publication. Your dev folder, local publishing, AI development and the spec check are npm run check and the spec check —all plaintext, exactly as before. The decision is made from a magic number inside the file rather than from the folder, so mixing the two kinds of package still works.

The one place you must change code: don't read files inside the package yourself

require() and <script src> decrypt automatically; reading with fs.readFileSync gives you garbage — and the error is something like “unexpected token”, pointing at your data rather than at “the file is ciphertext”.

✗ const tpl = fs.readFileSync(path.join(__dirname,'tpl.html'),'utf8')
✓ const tpl = require('./tpl.js').html        ← goes through require
✓ const cfg = require('./config.json')        ← require understands .json

To let users change settings, use host.storage; do not send them to edit a file inside the package — they cannot open it, and any edit is overwritten by the next update.

Being clear about who this stops

It stops team members and casual downloaders — they see binary, and that is as far as it goes. It does not stop a motivated reverse engineer: the ability to decrypt must live in the client, and the client is on their machine. This is the common ceiling of all local encryption, not something we did badly.

So: keep the genuinely core algorithm on your own server and have the app call an API. Encryption prevents casual inspection and copying; it does not make something uncrackable.

The “dev version” and the “published version” are two different things

The dev folder is source (hot-reloaded — change a line and it takes effect); what gets published is a snapshot. Edit the source without publishing again and your team members still run the old one — the card shows “dev v0.4.0 / published v0.2.1 — unpublished changes” to remind you.

Members cannot publish

Publishing pushes code to the whole team, and that is the main account's decision.

Authorisation — who may install your app

After publishing to the team, go to My apps → ⚙ Settings → 🔑 Authorisation and grant access by the other party's registered email. An authorised account does not have to be a member of your team — it can be any GatherSurf account.

PointNotes
Authorisation targets an accountAuthorise one account and they and every member of their team can use it. There is no need to add members one by one
The other party must be a paid accountA free account cannot open it even when authorised — their card shows “paid plan required”, and after upgrading it unlocks automatically, with no need to come back to you
ExpiryOpen-ended, or a specific date. It governs the authorisation only, independently of the other party's own plan renewal
RevokeThe app disappears from their Apps page, and an installed copy is uninstalled automatically at the next sync. The app data stays on their machine and is still there if you authorise them again
Three independent reasons for “authorised but won't open”

All three must hold; failing any one of them looks like “it won't open”, but the direction to investigate is completely different:

  1. The authorisation you granted — expired or revoked → check under Authorisation
  2. The other party's own plan — expired or on the free tier → they renew under Billing and it unlocks automatically
  3. The app version — delisted → publish a new version

In the authorisation list a free account is labelled “Free · cannot use”, precisely so you can see at a glance that it is reason 2.

Listing review

Submissions go into a queue and are read by a person. The version you already have listed is unaffected during review — users carry on with the old version while the new one queues separately.

What we look at

How you are notified of the result

A rejection always carries a reason

A rejection without a reason makes you guess again, and there are a dozen directions to guess (permissions? outbound? naming? size?). So the admin side will not let a rejection through without one. Fix it and resubmit — no process has to be repeated.

⚠ Pitfalls at a glance

These have all actually caused problems, and what they share is that they raise no error — which makes them very hard to diagnose afterwards, while avoiding them as you write costs almost nothing.

The rule that covers all of it: looking like it works ≠ working

Every item below is a variation on that sentence. There is only one criterion — have you compared the expected value against the actual value. Without a comparison, that step has not been verified, no matter how many “success” lines it printed.

“Silence ≠ fine”

Code that raises no error is not thereby working. The classic symptom is a number that stays at 0: a table was created and nothing writes to it, an event was never emitted, a parameter was accepted and never passed on. When you see “stays at 0”, suspect that the write path does not exist before you suspect the read logic.

encodeURIComponent encodes / as %2F

Use it on a path segment that legitimately contains a slash and the request goes to an address that does not exist:
'.../repos/' + encodeURIComponent('torvalds/linux').../repos/torvalds%2Flinux404.
and a 404 is usually read as “the resource does not exist”, so the UI says “repository not found: torvalds/linux” — a repository with 240,000 stars. What you encode is the parameter value, not a whole path.

★ Put the URL actually requested into the error message when an outbound call fails, and this class of problem becomes obvious at a glance.

Lumping every non-200 into one reason

404 (wrong address), 403 (rate-limited or unauthorised), 5xx (the other side is down) — the user handles each completely differently. Collapse them into “request failed” and all they can do is retry, which will most likely produce the same result.

A long task that reports no progress

invoke is one request, one response. When each round of a loop takes time (creating profiles, opening windows, making requests), returning only at the end means the UI is completely still for tens of seconds — the user assumes it has hung, then clicks repeatedly or closes and starts over.
Emit one host.ipc.send per completed round, and have the UI gsApp.on append a line.

One try/catch around a whole batch

The 7th of 20 fails, the user sees “batch failed”, and in fact 6 had already succeeded — they have no idea how many worked or which ones did not. Wrap each round in its own try, record the failure and carry on, then group and count by reason at the end.

A parameter accepted but never passed on

function f(a, b) { g(a); }——b Accepted and unused. The syntax is fine, the types are fine, tests may not cover it, and the feature simply has no effect.

Reading a key that does not exist

info.get('manifest') while the other side only returned name — you get undefined, fall into the default branch, and everything is “fine” except the result. Both sides are individually correct; the interface between them is not.

Double-encoded JSON

The database driver already serialises for you, and you called JSON.stringify as well — what gets stored is “a string that happens to be JSON”. .includes() may happen to still work (substring matching), while .map() / .length is wholly wrong.

Amounts in floating point

Always use integer cents. 0.1 + 0.2 !== 0.3 is a disaster at reconciliation time.

Mixing timestamp seconds and milliseconds

Pick one standard and write it in a comment. Mixing them shows up as “the time reads as the year 58000” — which is one of the easier ones to spot; worse is a comparison that silently goes wrong.

Look at it before declaring it done

A feature can be entirely correct while the layout is broken, and only a screenshot shows that. An API returning ok:true does not mean what the user sees is right.