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 write | Where it runs | What you get |
|---|---|---|
main.js | Client main process | host object — scoped to the permissions you declared |
ui/ | Client UI (the same window) | gsApp object — and nothing 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 check → Three 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 |
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)
- Left sidebar → “✨ AI Dev” → “+ New app” at the top right
- Give it a name, and fill in only the second half of the identifier — the prefix is added for you
- 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” - It will first ask which UI framework to use — just pick one (take the recommended one if unsure)
- When it is generated, click “▶ Preview” to run it and see the result
- Not happy? Keep talking: “add a region column”, “test 5 at a time”
- Happy? Click “📦 Add to My apps” and it becomes a real app you can publish
Path B: write it yourself
- “Apps” → “🛠 My apps” → “+ New app”
- Choose “📘 Example project” — that is a complete, working app with a clickable button for every API
- Click “Open” and run through it to see which API you actually need
- “📂 Open dev folder”, then edit with your usual editor
main.js - Save, go back to the client — it reloads automatically, no restart needed
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.
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
- Pick “📘 Example project” when creating an app, and you get a complete, working codebase
- Click “📂 Open dev folder” and hand the whole folder (including
AGENTS.md) to your AI - Just say what you want — “swap 1688 for Taobao”, “add an automatic price-drop alert”, “export to Excel instead”
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: ______
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)
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" }
}
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
| Permission | What it gives you | Notes |
|---|---|---|
storage | host.storage | Almost always needed |
db | host.db | Requires a paid plan |
http | host.http | Allowlist is optional; if the target domain is fixed, you should declare it. |
profiles:read | host.profiles Read only | |
profiles:control | Adds open/close | |
profiles:write | Create / edit / delete profiles, configure proxies | Can delete profiles — irreversible |
automation | host.automation | A key focus of review |
files:pick | host.files | Can only open a picker for the user |
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
| Capability | Method |
|---|---|
host.ipcNo permission needed | handle(name, fn)send(name, payload) |
host.storagestorageSynchronous | dir()list()read(n)readJson(n, d)remove(n)write(n, t)writeJson(n, o) |
host.dbdbSynchronous | close()exec(sql, params)migrate(list)path (value)query(sql, params, o)tx(fn) |
host.httphttpall need await | allowed()fetch(url, init)json(url, opt)mode()request(url, opt) |
host.profilesPer 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.automationautomationall 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.filesfiles:pickall 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 0The 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 */ };
{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| Member | What it is |
|---|---|
gsApp.appKey | Your app key |
gsApp.config | Per-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.jsA 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 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.
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.
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.
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 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.
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 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
| Item | Value | What happens if you exceed it |
|---|---|---|
| Rows returned by one query | 20000 | error, not silent truncation |
| One app's database file | 512 MB | writes are refused |
| One string / blob | 64 MB | error (don't put large files in the database — store the path with storage) |
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.
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
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.
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.
"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.
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.
When the other side returns 301/302:
- the domain in Location is in your
http.allow→ followed automatically, one hop only (to prevent redirect loops) - points outside the allowlist → not followed; it raises an error naming the domain
- No allowlist declared → never followed (there is no way to judge which destination is safe)
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
| Item | Value | What happens if you exceed it |
|---|---|---|
| One response body | 8 MB | error (paginate or chunk large files) |
| Rate | 600 requests / minute | reports “too many outbound requests” |
| Timeout | 30 seconds | the 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
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.
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 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.
// 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):
builtin: true—— Default group,id, fixed__ungrouped__. It is not a row in the database; it is the default value ofprofile.group,"Ungrouped".nameso what you get is the English string"Ungrouped"— convert it yourself before showing it to a person.orphan: true—— Unregistered group,id, of the form__orphan__xxx. Profiles carry this group name although the group was never created (this happens when you pass agroupfield directly while creating profiles through the API).- Neither flag = a real group, and
idis the database id.
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.
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.
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.
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.
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.
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 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.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.)
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.
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.
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'.
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.
Every tab is a renderer process. Run round after round without closing and memory climbs steadily.
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 UIThe 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
- 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
- Make sure it is open — skip if it already is; opening again runs a full startup and wastes several seconds
- 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
- Poll until the products render (see below)
evaluateScrape the data inside the page- Save a screenshot as evidence
- one transaction Bulk-write into the database
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”.
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”.
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
})()`);
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.
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.
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 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.
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);
readFile(path)
— that would give the app arbitrary read access. You only get the file the user actually picked.
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”).
| Product | Price | Status |
|---|---|---|
| Wireless earbuds A1 | ¥129.00 | Stored |
| Noise-cancelling headphones B2 | ¥299.00 | Skipped |
| Sports earbuds C3 | — | Price lookup failed |
gs-note-infogs-note-warngs-note-badClass reference
Buttons
| Class | Purpose |
|---|---|
gs-btn | Button (default) |
gs-btn-ghost | Ghost button (borderless, secondary action) |
gs-btn-pri | Primary button (blue; there should be only one per screen) |
gs-btn-sm | Small; stacks with the ones above |
Layout
| Class | Purpose |
|---|---|
gs-card | Card container (white, rounded, hairline border) |
gs-col | Vertical stack |
gs-pad | Adds padding inside a card |
gs-row | Horizontal, wraps automatically |
gs-spacer | Spacer that pushes what follows to the right |
gs-toolbar | Top toolbar (horizontal + bottom margin) |
Data display
| Class | Purpose |
|---|---|
gs-empty | Empty state (the “no data yet” block) |
gs-item | One row of a list |
gs-list | List container |
Forms
| Class | Purpose |
|---|---|
gs-field | One form group (label + control) |
gs-inp | Single-line input |
gs-label | Form label |
gs-sel | Select |
gs-ta | Textarea |
gs-table | Data table |
gs-tag | Tags / badges |
gs-tag-bad | Red tag (failure) |
gs-tag-gray | Grey tag (disabled / neutral) |
gs-tag-ok | Green tag (success) |
Marks and notices
| Class | Purpose |
|---|---|
gs-hint | Small caption under a control |
gs-mono | Monospace (IDs, paths, amounts) |
gs-note | Notice block (colour set by the three below) |
gs-note-bad | Red · something went wrong |
gs-note-info | Blue · general note |
gs-note-warn | Orange · pay attention |
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
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" },
...
}
| framework | Notes | Size |
|---|---|---|
bootstrap5 | Bootstrap 5.3.3, Includes JS components (modal / dropdown / collapse / tabs / carousel / tooltip) | CSS 258KB + JS 81KB |
milligram | Minimal, and class-free — write semantic HTML and you get styling. Pure CSS | 22KB |
daft | Class-free, modern look (close to the feel of shadcn/ui). Pure CSS | 86KB |
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.
Prefix = the first letter of each segment: abc-price-helper → aph. 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.
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.
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>
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
- No CDNs. The page CSP is
default-src 'self', so no external address will load at all. You must download the framework files and merge their contents intoui/style.css— the UI loads only that one CSS file (plusindex.htmlandapp.js), and extra files are simply never read. - The framework's JS components (dropdowns, modals) likewise cannot come from a CDN; merge them into
ui/app.js, and that file must be an IIFE. - Size counts against the app package, capped at 20MB. Bootstrap is about 230KB minified — not a problem.
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.
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-example → a8e prepended. The spec check tells you which prefix to use.
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.
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:
| Identity | Looks like |
|---|---|
| Package path in the cloud | apps/smartmob-sourcing/0.1.0/… |
| App directory name on the machine | dev-apps/smartmob-sourcing/ |
| IPC routing key | How 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.
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.
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
| Suggested | Avoid | |
|---|---|---|
| Developer ID | smartmob zhiqu-tech |
a1 (too short to be recognisable), my-company-tech-dept (too long — every key has to carry it) |
| App name part | sourcing 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.
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
| Where | Style | Example |
|---|---|---|
| JS variables / functions / object keys | camelCase | goodsList fetchPrice() |
| JS classes / constructors | PascalCase | PriceTracker |
| Constants (genuinely constant) | UPPER_SNAKE | MAX_RETRY |
| IPC channel names | module:action | goods:list collect:start |
| CSS class | prefix-name | .x8t-card (the prefix is generated by the platform — do not invent your own) |
| File names | kebab-case | price-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 write | Write | Why |
|---|---|---|
d tmp data2 | goods draftRow mergedGoods | In three months you would have to re-read it to know what it is |
flag status | isRunning collectState | Booleans start with is/has/can so you can see at a glance to treat it as true/false |
time | createdAt durationMs | Carry the unit. durationMs, createdAtMs, priceCents — with the unit in the name there is no convention to remember |
price | priceCents | Amounts use integer cents, never floats |
getUser() (it actually writes to the database) | fetchUser() / saveUser() | get Suggests no side effects |
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
}
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.
| Database | Who writes | Unit | Column name |
|---|---|---|---|
Your host.db | Your JS code | Milliseconds | created_at_ms |
| Our cloud PostgreSQL | Python service | Seconds | created_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
| Rule | Notes |
|---|---|
| Table names are plural | goods suppliers price_history |
The primary key is simply id | Foreign keys are <table-singular>_id: supplier_id |
Time columns always end in _at_ms | created_at_ms updated_at_ms. Not plain at, and not last_login |
Store timestamps as Date.now()milliseconds, via INTEGER | The writing side is JS and Date.now() is milliseconds already —no conversion means no mis-conversion |
| Amounts are integer cents | price_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 abbreviations | Only 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 name | password_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.
- One SQL statement per
upelement. Several statements in one string means only the first executes and the rest are silently discarded — while the version is recorded anyway, so that migration never re-runs and the missing tables and indexes never exist. The platform rejects this and raises before touching the database. - Table and column names must be hard-coded literals. A table name containing a timestamp or a random number, paired with a fixed
v, means the second start gets “new table name + old version number”, the whole migration is skipped, and every SQL statement after itno such table. - A version number, once used, must not have its content changed. Change it and the platform throws immediately and tells you which new version to use — because without the throw, none of that SQL would run and the caller would see nothing unusual.
Use exec(sql, params) for insert, update and delete; query(sql, params) for queries; and wrap a batch of writes in tx(fn).
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.
⚠ 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 suits | Those who want to ship a tool to other people | Those with their own stack who just want to drive the browser |
| Deliverable | A publishable app card | Your own script |
| Users | People who installed your app, no technical knowledge needed | Just you |
| How you call it | host.profiles.list() | GET /gs/v1/profiles |
| Authentication | Manifest permission declarations | X-GS-Token |
| Prerequisite | The user installed your app | The client is running + enabled in Settings |
How to enable
- Client → Settings →Local Automation API→ turn on
- The same page shows your token (the account-level API token)
- The address is fixed at
http://127.0.0.1:48090and listens on the local machine only
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.
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
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`, {});
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
| Capability | Notes |
|---|---|
| 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.
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.
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
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
| Endpoint | Notes |
|---|---|
GET /health | Liveness. The only one that needs no token |
GET /account | Current account + quota (how many more profiles you can create) |
GET /status | pid / uptime / version / how many profiles are open |
GET /settings · PATCH /settings | Configurable kernelPath / headful / kernelVersion |
POST /server/restart | Restarts the client; the same port is back in 3–8 seconds |
POST /server/shutdown | Quits the client |
POST /server/kill-orphans | Cleans up kernel processes that did not exit cleanly |
Profiles
| Endpoint | Parameters / notes |
|---|---|
GET /profiles | All 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 /profiles | name 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}/launch | Opens 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}/shutdown | Closes the window. Graceful close: tabs are saved to disk and restored the next time the profile opens |
POST /profiles/launch-new | Create and open in one step |
POST /profiles/{id}/copy | count(≤50) keepFingerprint. Uses a fresh fingerprint by default. Does not copy passwords or cookies |
POST /profiles/{id}/clear-cache | cookies (cookies are cleared too) keepExtensions (default true) |
GET /sessions | Currently open profiles plus each one's CDP address |
Fingerprints
| Endpoint | Notes |
|---|---|
POST /fingerprint/preview | Generates only, without creating a profile, so it uses no quota. Look first, then decide |
GET /profiles/{id}/export | Exports a fingerprint bundle (seed+archetype+overrides) |
POST /profiles/import | Restores the same fingerprint from a bundle, across accounts |
POST /profiles/{id}/refresh-fingerprint | Assigns a new fingerprint. The profile must be closed first, otherwise 409. An alias /randomize — the same endpoint; old scripts may use this name |
(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
| Endpoint | Notes |
|---|---|
GET /profiles/{id}/cookies | Export. 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}/cookies | Import. Requires the profile to be running, otherwise 409. Injected over CDP and persisted to disk |
Batch
| Endpoint | Parameters |
|---|---|
POST /profiles/batch/launch | ids intervalMs (waits a moment between each) kernelVersion |
POST /profiles/batch/shutdown | ids or all:true |
PATCH /profiles/batch | ids + group/tags/remark, one of |
POST /profiles/batch/delete | ids |
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
| Endpoint | Notes |
|---|---|
GET /proxies · GET /proxies/{id} | List / single |
POST /proxies | line (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/check | Check an unsaved string: line timeoutMs. Verify before creating profiles |
POST /proxies/{id}/check | Check a stored one; the result is written back (exit IP / country / status) |
GET /proxy-tags | Aggregates 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
| Endpoint | Notes |
|---|---|
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 /tags | Aggregates 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 · /restore | Soft delete / restore |
GET /trash · POST /trash/empty | Recycle 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
| Endpoint | Notes |
|---|---|
GET /kernels | Which versions exist, whether installed, whether an update is due |
GET /kernels/{v}/status | Whether this version is the latest |
POST /kernels/{v}/upgrade | Download / self-heal to the latest build |
POST /kernels/{v}/default | Set as default |
DELETE /kernels/{v} | Remove this version |
POST /profiles/{id}/kernel | Pin 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})
- Close the profile before changing the fingerprint — otherwise 409. The new fingerprint takes effect on the next open, and the local data is still the old data
- Importing cookies requires the profile to be running — exporting does not (it works closed), importing does
- A batch endpoint does not stop the whole batch on one failure — always read
resultsevery entry in it, not just thesucceededcount
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 checked | Why |
|---|---|
| manifest complete, version is x.y.z | A non-conforming version makes update decisions impossible |
| Directory name matches manifest.key | A mismatch installs it in the wrong place |
| Every permission is on the allowlist | One 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_modules | App packages carry no dependencies |
| README.md present | Whoever installs it needs to know what it is |
No require('electron') / require('fs') | to bypass the sandbox |
No bare ipcMain.handle | The platform has no idea that is your endpoint |
Exports register | Without it the app cannot load |
| UI is a fragment, CSS is prefixed, app.js is an IIFE | See 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.
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 it | Code uploaded | Review required | What others download | |
|---|---|---|---|---|
| 💻 Local | This device only | No | No | — |
| 👥 Team | Members you authorise | Uploaded to the cloud | No, effective immediately | Encrypted package |
| 🌐 Public listing | All GatherSurf users | Uploaded to the cloud | Yes, reviewed by a person | Encrypted 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.
| Stage | Form | Why |
|---|---|---|
| You package and upload | Plaintext | The 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 server | The plaintext original | Needed for re-review, support investigations, and for you to look back at |
| Published / review passed | Encrypted once | One random key per version, saved as the distribution artefact |
| User downloads | Ciphertext | Decrypted in memory only at runtime, never written to disk |
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.
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.
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 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.
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.
| Point | Notes |
|---|---|
| Authorisation targets an account | Authorise 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 account | A 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 |
| Expiry | Open-ended, or a specific date. It governs the authorisation only, independently of the other party's own plan renewal |
| Revoke | The 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 |
All three must hold; failing any one of them looks like “it won't open”, but the direction to investigate is completely different:
- The authorisation you granted — expired or revoked → check under Authorisation
- The other party's own plan — expired or on the free tier → they renew under Billing and it unlocks automatically
- 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
- Whether the permissions are minimal — you requested
automationbut never actually use it? - Whether the outbound allowlist is reasonable — any wildcards? where is data being sent?
- The code itself — the review UI shows every file in the package, and a mismatch between declaration and implementation gets noticed
How you are notified of the result
- The client raises a notification and puts a badge on Apps in the sidebar
- A status line stays on the card; clicking “Details” shows the full review history — how many times it was submitted and the reason for each rejection
- You may leave a contact email on submission, and the reviewer sends the result there
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.
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.
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%2Flinux → 404.
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.
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.
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.
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.
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.
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.
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.
Always use integer cents. 0.1 + 0.2 !== 0.3 is a disaster at reconciliation time.
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.
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.