@scoutmesh/viewer 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,448 @@
1
+ import { execFile } from "node:child_process";
2
+ import { createHash, randomUUID } from "node:crypto";
3
+ import {
4
+ closeSync,
5
+ copyFileSync,
6
+ cpSync,
7
+ existsSync,
8
+ mkdirSync,
9
+ mkdtempSync,
10
+ openSync,
11
+ readFileSync,
12
+ renameSync,
13
+ rmSync,
14
+ writeFileSync,
15
+ } from "node:fs";
16
+ import path from "node:path";
17
+ import { promisify } from "node:util";
18
+
19
+ import { writeStateFile } from "./state-file.js";
20
+
21
+ const execute = promisify(execFile);
22
+
23
+
24
+
25
+
26
+
27
+
28
+
29
+
30
+
31
+
32
+
33
+
34
+
35
+
36
+
37
+
38
+ function object(value ) {
39
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
40
+ throw new Error("Expected an object.");
41
+ }
42
+ return Object.fromEntries(Object.entries(value));
43
+ }
44
+ function readJson(file ) {
45
+ return object(
46
+ JSON.parse(readFileSync(file, "utf-8").replace(/^\uFEFF/u, ""))
47
+ );
48
+ }
49
+ function version(value ) {
50
+ if (typeof value !== "string" || !/^\d+\.\d+\.\d+$/u.test(value)) {
51
+ throw new Error("Invalid release version.");
52
+ }
53
+ return value;
54
+ }
55
+ function installation(root ) {
56
+ const input = readJson(path.join(root, "launcher.json"));
57
+ for (const key of ["cli", "node", "npm", "stateDir"]) {
58
+ if (typeof input[key] !== "string" || !path.isAbsolute(input[key])) {
59
+ throw new Error(`Invalid launcher ${key}. Rerun the official bootstrap.`);
60
+ }
61
+ }
62
+ if (
63
+ typeof input.port !== "number" ||
64
+ !Number.isInteger(input.port) ||
65
+ (input.port !== 0 && input.port < 1024) ||
66
+ input.port > 65_535 ||
67
+ typeof input.releaseUrl !== "string"
68
+ ) {
69
+ throw new Error("Invalid launcher settings.");
70
+ }
71
+ return {
72
+ cli: String(input.cli),
73
+ node: String(input.node),
74
+ npm: String(input.npm),
75
+ port: input.port,
76
+ releaseUrl: input.releaseUrl,
77
+ stateDir: String(input.stateDir),
78
+ version: version(input.version),
79
+ };
80
+ }
81
+
82
+ function releaseUrl(value ) {
83
+ const url = new URL(value);
84
+ if (
85
+ url.username ||
86
+ url.password ||
87
+ url.hash ||
88
+ (url.protocol !== "https:" &&
89
+ !(url.protocol === "http:" && url.hostname === "127.0.0.1"))
90
+ ) {
91
+ throw new Error(
92
+ "Release URLs must use HTTPS (HTTP loopback is allowed for local tests only)."
93
+ );
94
+ }
95
+ return url;
96
+ }
97
+ async function download(url , limit ) {
98
+ const response = await fetch(url, {
99
+ cache: "no-store",
100
+ redirect: "error",
101
+ signal: AbortSignal.timeout(20_000),
102
+ });
103
+ if (!response.ok || !response.body) {
104
+ throw new Error(`Release download failed: HTTP ${response.status}.`);
105
+ }
106
+ const chunks = [];
107
+ let size = 0;
108
+ for await (const chunk of response.body) {
109
+ size += chunk.length;
110
+ if (size > limit) {
111
+ throw new Error("Release download exceeds its size limit.");
112
+ }
113
+ chunks.push(chunk);
114
+ }
115
+ return Buffer.concat(chunks);
116
+ }
117
+ async function getRelease(address ) {
118
+ const url = releaseUrl(address);
119
+ const bytes = await download(url, 64 * 1024);
120
+ const input = object(JSON.parse(bytes.toString("utf-8")));
121
+ if (
122
+ typeof input.sha256 !== "string" ||
123
+ !/^[a-f0-9]{64}$/u.test(input.sha256) ||
124
+ typeof input.archiveUrl !== "string" ||
125
+ input.nodeMajor !== 24
126
+ ) {
127
+ throw new Error(
128
+ "Invalid release manifest or unsupported runtime. Use the current official bootstrap if a newer Node runtime is required."
129
+ );
130
+ }
131
+ const archive = releaseUrl(input.archiveUrl);
132
+ if (archive.origin !== url.origin) {
133
+ throw new Error(
134
+ "The release archive must have the same origin as its manifest."
135
+ );
136
+ }
137
+ return {
138
+ archiveUrl: archive.href,
139
+ nodeMajor: input.nodeMajor,
140
+ sha256: input.sha256,
141
+ version: version(input.version),
142
+ };
143
+ }
144
+ function newer(next , current ) {
145
+ const left = next.split(".").map(Number);
146
+ const right = current.split(".").map(Number);
147
+ const index = left.findIndex((part, i) => part !== right[i]);
148
+ return index !== -1 && (left[index] ?? 0) > (right[index] ?? 0);
149
+ }
150
+ function environment(config ) {
151
+ const env = {
152
+ ...process.env,
153
+ SCOUTMESH_VIEWER_HOME: config.stateDir,
154
+ VIEWER_PORT: String(config.port),
155
+ };
156
+ delete env.VIEWER_STATE_FILE;
157
+ return env;
158
+ }
159
+ async function run(config , args ) {
160
+ return await execute(config.node, [config.cli, ...args], {
161
+ env: environment(config),
162
+ timeout: 30_000,
163
+ windowsHide: true,
164
+ });
165
+ }
166
+ async function status(config ) {
167
+ const result = await run(config, ["status"]);
168
+ return object(JSON.parse(result.stdout));
169
+ }
170
+ async function startChecked(config ) {
171
+ const started = await run(config, ["start"]);
172
+ const result = await status(config);
173
+ if (result.status !== "running" || result.version !== config.version) {
174
+ throw new Error(
175
+ `Expected running viewer ${config.version}, received ${String(result.version)}.`
176
+ );
177
+ }
178
+ return {
179
+ ...result,
180
+ reused: object(JSON.parse(started.stdout)).reused === true,
181
+ };
182
+ }
183
+ async function stageRelease(
184
+ root ,
185
+ current ,
186
+ release
187
+ ) {
188
+ const directory = path.join(
189
+ root,
190
+ "packages",
191
+ `${release.version}-${release.sha256.slice(0, 12)}`
192
+ );
193
+ const cli = path.join(
194
+ directory,
195
+ "node_modules/@scoutmesh/viewer/dist/cli.js"
196
+ );
197
+ const receipt = path.join(directory, ".verified.json");
198
+ if (
199
+ existsSync(cli) &&
200
+ existsSync(receipt) &&
201
+ readJson(receipt).sha256 === release.sha256
202
+ ) {
203
+ return { ...current, cli, version: release.version };
204
+ }
205
+ const bytes = await download(
206
+ releaseUrl(release.archiveUrl),
207
+ 20 * 1024 * 1024
208
+ );
209
+ if (createHash("sha256").update(bytes).digest("hex") !== release.sha256) {
210
+ throw new Error(
211
+ "Release checksum mismatch. The running viewer was not changed."
212
+ );
213
+ }
214
+ const stage = mkdtempSync(path.join(root, ".update-stage-"));
215
+ try {
216
+ const archive = path.join(stage, "viewer.tgz");
217
+ writeFileSync(archive, bytes, { mode: 0o600 });
218
+ await execute(
219
+ current.node,
220
+ [
221
+ current.npm,
222
+ "install",
223
+ "--offline",
224
+ "--ignore-scripts",
225
+ "--no-audit",
226
+ "--no-fund",
227
+ "--cache",
228
+ path.join(root, "npm-cache"),
229
+ "--prefix",
230
+ directory,
231
+ archive,
232
+ ],
233
+ { timeout: 60_000, windowsHide: true }
234
+ );
235
+ const manifest = readJson(
236
+ path.join(directory, "node_modules/@scoutmesh/viewer/package.json")
237
+ );
238
+ if (
239
+ manifest.name !== "@scoutmesh/viewer" ||
240
+ manifest.version !== release.version
241
+ ) {
242
+ throw new Error("Release package identity does not match its manifest.");
243
+ }
244
+ writeStateFile(receipt, {
245
+ sha256: release.sha256,
246
+ version: release.version,
247
+ });
248
+ return { ...current, cli, version: release.version };
249
+ } catch (error) {
250
+ rmSync(directory, { force: true, recursive: true });
251
+ throw error;
252
+ } finally {
253
+ rmSync(stage, { force: true, recursive: true });
254
+ }
255
+ }
256
+
257
+ async function switchRelease(
258
+ root ,
259
+ current ,
260
+ next
261
+ ) {
262
+ const state = path.join(current.stateDir, "state.json");
263
+ const backup = path.join(
264
+ current.stateDir,
265
+ "backups",
266
+ `upgrade-${Date.now()}-${randomUUID()}`
267
+ );
268
+ mkdirSync(backup, { mode: 0o700, recursive: true });
269
+ // Stop the writer before capturing a consistent snapshot, including last-second UI decisions.
270
+ await run(current, ["stop"]);
271
+ const hadState = existsSync(state);
272
+ try {
273
+ if (hadState) {
274
+ copyFileSync(state, path.join(backup, "before.json"));
275
+ }
276
+ writeStateFile(path.join(backup, "installation.json"), current);
277
+ } catch (error) {
278
+ await startChecked(current);
279
+ throw new Error(
280
+ "Could not create an upgrade backup. The previous viewer was restarted; no update was applied.",
281
+ { cause: error }
282
+ );
283
+ }
284
+ try {
285
+ const running = await startChecked(next);
286
+ writeStateFile(path.join(root, "launcher.json"), next);
287
+ return { ...running, backup, update: "updated" };
288
+ } catch (error) {
289
+ // Never restore a snapshot over a live or unreachable server.
290
+ try {
291
+ await run(next, ["stop"]);
292
+ } catch {
293
+ throw new Error(
294
+ `Upgrade could not be stopped safely. State was not restored. Recovery files: ${backup}.`,
295
+ { cause: error }
296
+ );
297
+ }
298
+ if (existsSync(state)) {
299
+ copyFileSync(state, path.join(backup, "failed.json"));
300
+ }
301
+ if (hadState) {
302
+ writeStateFile(
303
+ state,
304
+ JSON.parse(readFileSync(path.join(backup, "before.json"), "utf-8"))
305
+ );
306
+ } else {
307
+ rmSync(state, { force: true });
308
+ }
309
+ writeStateFile(path.join(root, "launcher.json"), current);
310
+ const running = await startChecked(current);
311
+ console.error(
312
+ `Update failed; restored viewer ${current.version}. ${error instanceof Error ? error.message : "Startup failed."}`
313
+ );
314
+ return { ...running, backup, update: "rolled-back" };
315
+ }
316
+ }
317
+
318
+ async function updateAndStart(root , current ) {
319
+ if (!current.releaseUrl) {
320
+ return { ...(await startChecked(current)), update: "not-configured" };
321
+ }
322
+ let release ;
323
+ let next ;
324
+ try {
325
+ release = await getRelease(current.releaseUrl);
326
+ if (!newer(release.version, current.version)) {
327
+ return {
328
+ ...(await startChecked(current)),
329
+ update:
330
+ release.version === current.version ? "current" : "newer-installed",
331
+ };
332
+ }
333
+ next = await stageRelease(root, current, release);
334
+ } catch (error) {
335
+ console.error(
336
+ `Update not applied: ${error instanceof Error ? error.message : "Release check failed."} Using installed viewer ${current.version}.`
337
+ );
338
+ return { ...(await startChecked(current)), update: "unavailable" };
339
+ }
340
+ return await switchRelease(root, current, next);
341
+ }
342
+
343
+ async function withLock (root , action ) {
344
+ const lockFile = path.join(root, ".update.lock");
345
+ let lock ;
346
+ try {
347
+ lock = openSync(lockFile, "wx", 0o600);
348
+ } catch {
349
+ throw new Error(
350
+ `Another launcher command owns ${lockFile}. Retry after it finishes. If interrupted, confirm no updater is running before removing this lock.`
351
+ );
352
+ }
353
+ try {
354
+ writeFileSync(lock, String(process.pid));
355
+ return await action();
356
+ } finally {
357
+ closeSync(lock);
358
+ rmSync(lockFile, { force: true });
359
+ }
360
+ }
361
+
362
+ export async function managedCommand(root , args ) {
363
+ await withLock(root, async () => {
364
+ const config = installation(root);
365
+ if (args[0] === "start") {
366
+ console.info(JSON.stringify(await updateAndStart(root, config)));
367
+ } else {
368
+ const result = await run(config, args);
369
+ process.stdout.write(result.stdout);
370
+ process.stderr.write(result.stderr);
371
+ }
372
+ });
373
+ }
374
+
375
+ // npm selects and verifies the requested package. Keep a durable copy so clearing
376
+ // the npm cache cannot remove a running viewer or its rollback version.
377
+ export async function launchPackage(packageDir , stateDir ) {
378
+ const manifest = readJson(path.join(packageDir, "package.json"));
379
+ if (manifest.name !== "@scoutmesh/viewer") {
380
+ throw new Error("Unexpected npm package identity.");
381
+ }
382
+ const requested = version(manifest.version);
383
+ const root = path.join(stateDir, ".viewer");
384
+ mkdirSync(root, { mode: 0o700, recursive: true });
385
+ return await withLock(root, async () => {
386
+ const configFile = path.join(root, "launcher.json");
387
+ const current = existsSync(configFile) ? installation(root) : undefined;
388
+ const launcher = path.join(root, "launcher.mjs");
389
+ if (current && !newer(requested, current.version)) {
390
+ return {
391
+ ...(await startChecked(current)),
392
+ launcher,
393
+ node: current.node,
394
+ update: requested === current.version ? "current" : "newer-installed",
395
+ };
396
+ }
397
+ const packages = path.join(root, "packages");
398
+ mkdirSync(packages, { mode: 0o700, recursive: true });
399
+ const staged = mkdtempSync(path.join(packages, ".stage-"));
400
+ const destination = path.join(packages, `${requested}-${randomUUID()}`);
401
+ try {
402
+ for (const name of ["package.json", "dist", "skills", "README.md"]) {
403
+ cpSync(path.join(packageDir, name), path.join(staged, name), {
404
+ recursive: true,
405
+ });
406
+ }
407
+ renameSync(staged, destination);
408
+ } finally {
409
+ rmSync(staged, { force: true, recursive: true });
410
+ }
411
+ const next = {
412
+ cli: path.join(destination, "dist/cli.js"),
413
+ node: process.execPath,
414
+ // No registry calls are made by this launcher. npm resolves the package
415
+ // before launch; keep this path for the shared installation format.
416
+ npm: path.resolve(
417
+ process.env.npm_execpath ??
418
+ path.join(
419
+ path.dirname(process.execPath),
420
+ process.platform === "win32"
421
+ ? "node_modules/npm/bin/npm-cli.js"
422
+ : "../lib/node_modules/npm/bin/npm-cli.js"
423
+ )
424
+ ),
425
+ port: current?.port ?? Number(process.env.VIEWER_PORT ?? 0),
426
+ releaseUrl: "",
427
+ stateDir,
428
+ version: requested,
429
+ };
430
+ if (
431
+ !Number.isInteger(next.port) ||
432
+ (next.port !== 0 && next.port < 1024) ||
433
+ next.port > 65_535
434
+ ) {
435
+ throw new Error("VIEWER_PORT must be 0 or between 1024 and 65535.");
436
+ }
437
+ // The existing launcher remains compatible; do not replace it during an
438
+ // upgrade that may roll back.
439
+ if (!current) {
440
+ copyFileSync(path.join(destination, "dist/launcher.js"), launcher);
441
+ const running = await startChecked(next);
442
+ writeStateFile(configFile, next);
443
+ return { ...running, launcher, node: next.node, update: "installed" };
444
+ }
445
+ const result = await switchRelease(root, current, next);
446
+ return { ...result, launcher, node: installation(root).node };
447
+ });
448
+ }
@@ -0,0 +1 @@
1
+ export const viewerVersion = "0.2.0";
package/package.json ADDED
@@ -0,0 +1,20 @@
1
+ {
2
+ "name": "@scoutmesh/viewer",
3
+ "version": "0.2.0",
4
+ "description": "Local candidate cards and JSON workspaces for ScoutMesh agents",
5
+ "bin": {
6
+ "scoutmesh-viewer": "dist/cli.js"
7
+ },
8
+ "files": [
9
+ "dist/",
10
+ "skills/",
11
+ "README.md"
12
+ ],
13
+ "type": "module",
14
+ "publishConfig": {
15
+ "access": "public"
16
+ },
17
+ "engines": {
18
+ "node": ">=24"
19
+ }
20
+ }
@@ -0,0 +1,200 @@
1
+ ---
2
+ name: local-candidate-viewer
3
+ description: Present ScoutMesh candidates as local interactive cards, append batches, and read shortlist decisions. Requires an agent with terminal access and a browser on the user's computer.
4
+ ---
5
+
6
+ # Local candidate workspace
7
+
8
+ Use the viewer when the user wants to review candidates visually or continue an
9
+ existing candidate workspace. Keep ScoutMesh searches and billing in the existing
10
+ connector. The viewer only receives already retrieved data; it does not search,
11
+ enrich, verify contacts, or send messages. Candidate data is saved locally as JSON.
12
+
13
+ ## Start and open
14
+
15
+ The agent needs a terminal and a browser that can reach the same computer's
16
+ localhost. A cloud-only chat cannot start or reach this viewer through the remote
17
+ ScoutMesh connector. Explain that limitation if local execution is unavailable;
18
+ use the usual chat presentation without claiming a page was opened.
19
+
20
+ This is an unpublished preview. Until ScoutMesh confirms a published version,
21
+ use the supplied local build or verified bootstrap; do not assume the package is
22
+ available. Once confirmed, prefer npm when Node.js 24+ and npm are available:
23
+
24
+ ```bash
25
+ npx --yes --ignore-scripts --registry=https://registry.npmjs.org @scoutmesh/viewer@<approved-version> launch
26
+ ```
27
+
28
+ Substitute the exact version supplied by ScoutMesh, never an invented version.
29
+ Run this command at each new review session so the approved version can replace
30
+ an older running version. `launch` keeps durable package copies outside npm's
31
+ cache and uses state backup, health checks, and rollback during upgrades. It
32
+ returns `node` and `launcher` absolute paths. For subsequent commands below,
33
+ `scoutmesh-viewer` means `"<node>" "<launcher>"`; on Windows use
34
+ `& '<node>' '<launcher>'`. It is not assumed to be on PATH. Keep using this
35
+ launcher for the session, including token and request commands.
36
+ The saved launcher's `start` can restart offline but does not check npm. `current`
37
+ compares the installed version with the requested version; it does not establish
38
+ that a cached version is the newest public release. If npm cannot download the
39
+ approved version, the existing launcher may be used with that limitation stated.
40
+
41
+ If Node/npm is absent, use the bootstrap ZIP URL and SHA-256 supplied by ScoutMesh.
42
+ Download to a file, verify the checksum, then extract it. Do not invent a release
43
+ URL or pipe downloads into a shell. Run `/bin/bash /absolute/path/to/bootstrap.sh`
44
+ on macOS or `powershell.exe -NoProfile -File C:\absolute\path\bootstrap.ps1` on
45
+ Windows. It downloads a pinned official Node runtime if needed, checks its
46
+ checksum, and installs the bundled viewer offline with lifecycle scripts disabled.
47
+ Use the returned bootstrap launcher for every command in that session. It checks
48
+ its configured release channel on `start`; an unpublished bundle may have none.
49
+
50
+ Preserve the installation method and state directory for an existing workspace.
51
+ Do not alternate between npm and bootstrap launchers against the same state file.
52
+ Changing delivery methods requires stopping the existing viewer first and
53
+ explicitly preserving its state directory. npm defaults to
54
+ `~/.scoutmesh/workspaces` on both platforms; `SCOUTMESH_VIEWER_HOME` selects another
55
+ directory. npm `launch` rejects `VIEWER_STATE_FILE`. The bootstrap defaults to
56
+ `~/.scoutmesh/workspaces` on macOS and `%LOCALAPPDATA%\ScoutMesh\workspaces` on
57
+ Windows. Do not change locations merely because a command fails.
58
+
59
+ The host may require approval for downloads, local processes, localhost access,
60
+ or PowerShell scripts. Use its normal approval mechanism. If localhost is blocked,
61
+ retry the documented CLI with approved local access. Do not bypass a rejection by
62
+ reading runtime credentials manually, changing execution policy, or disabling auth.
63
+ Report that limitation if access is unavailable; do not claim the page was opened.
64
+
65
+ 1. Start the session with the versioned npm `launch` command above, or the existing
66
+ bootstrap launcher’s `start`. It returns JSON with `url`, `stateFile`, and
67
+ whether it reused a running process, its `version`, and `update` result.
68
+ `installed` means the first npm installation;
69
+ `current` means the requested npm version or configured channel matches; `updated` means a verified upgrade;
70
+ `newer-installed` means no downgrade was attempted. `unavailable` uses the
71
+ installed version because the release check/install failed. `rolled-back`
72
+ means an upgrade failed and the previous package/state were restored.
73
+ `not-configured` means this launcher has no HTTPS release channel; npm updates
74
+ are selected by the session’s versioned npm command. Report
75
+ unavailable/rolled-back/not-configured honestly; never call them up to date.
76
+ This starts a background process, not a
77
+ login service. Start a new session after a laptop restart.
78
+ 2. Run `scoutmesh-viewer request GET /api/views`. Reuse the workspace for this
79
+ role/search. Create a new one only for a distinct user task. Read its records
80
+ and decisions before another search so removed or already reviewed people
81
+ are not presented as new; local deduplication cannot prevent provider charges.
82
+ 3. Write the create/update payload to a local JSON file using a file tool, then
83
+ pass the path to `request`. Do not interpolate candidate text into shell code.
84
+ Keep temporary candidate files private and remove them when finished.
85
+ 4. Open the returned workspace URL in the agent's browser. If it shows Unlock,
86
+ read `scoutmesh-viewer token` locally and fill the Local access token field,
87
+ then click Unlock workspace. Never paste the token in chat, a URL, a search,
88
+ or a remote tool. The browser gets an HttpOnly session cookie. A server
89
+ restart rotates the token; reload and unlock the page again if needed.
90
+ 5. Confirm cards are visible before saying they were shown. An API response
91
+ confirms a saved mutation, not successful browser rendering.
92
+
93
+ The bootstrap saves `state.json` under `~/.scoutmesh/workspaces` on macOS and
94
+ `%LOCALAPPDATA%\ScoutMesh\workspaces` on Windows. This is outside the installation.
95
+ The direct npm CLI also accepts `SCOUTMESH_VIEWER_HOME` or `VIEWER_STATE_FILE`.
96
+ `VIEWER_PORT=0` chooses an available port, which can change on restart: always use
97
+ the returned URL. `status` and `stop` affect only the authenticated viewer for this
98
+ state file. The managed launcher stops/restarts safely for an update. Stop before
99
+ manually replacing package files or restoring JSON. Use the returned URL after an
100
+ upgrade and unlock again; the port and local session may change.
101
+
102
+ A release channel is embedded in official bootstrap bundles. If ScoutMesh supplies
103
+ one separately, pass `--release-url` on macOS or `-ReleaseUrl` on Windows when
104
+ running the installer. Never invent a release URL or silently switch the source.
105
+ The updater preserves snapshots in the state directory and refuses to restore
106
+ over a live/unreachable process. An interrupted upgrade can need manual recovery;
107
+ keep both snapshots and report the diagnostic paths rather than clearing state.
108
+
109
+ ## Send candidates
110
+
111
+ Create with `scoutmesh-viewer request POST /api/views <create.json>`:
112
+
113
+ ```json
114
+ {
115
+ "id": "engineering-amsterdam",
116
+ "title": "Engineering managers · Amsterdam",
117
+ "records": [
118
+ {
119
+ "id": "provider:stable-id",
120
+ "name": "Example candidate",
121
+ "role": "Engineering manager",
122
+ "company": "Example company",
123
+ "location": "Amsterdam",
124
+ "fitSummary": "Their profile reports leading a platform team.",
125
+ "unconfirmed": "Team size and current availability are not confirmed.",
126
+ "batchId": "batch-1"
127
+ }
128
+ ]
129
+ }
130
+ ```
131
+
132
+ This is illustrative data; send the actual search results, never these examples
133
+ as real people. Use stable provider IDs; reuse the existing ID for cross-source
134
+ enrichment only after confirming the identity. Never match by name alone.
135
+ Candidate fields accept strings, finite numbers, booleans, null, and string arrays.
136
+ Flatten nested provider data into useful fields without losing source qualifiers.
137
+
138
+ Cards use `name`, `role`, `company`, `location`, `photoUrl`, `fitSummary`, and
139
+ `unconfirmed`. Details use `justification`, `gaps`, skills and other supplied
140
+ fields. Include `linkedin`, `github`, `website`, `email`, `emailStatus`,
141
+ `photoSource`, and `photoSourceUrl` only when supported by the returned data.
142
+ Photos must come from the candidate's source; do not invent or generate them.
143
+ Keep a concise role-specific reason and the important unknowns visible in each
144
+ card. A title or repository is evidence of an activity, not proof of proficiency.
145
+ Preserve named source links, dates, and contact verification provenance. Do not
146
+ mark an email independently verified based solely on a provider's claim.
147
+
148
+ For another batch or enrichment, use
149
+ `scoutmesh-viewer request PATCH /api/views/engineering-amsterdam <update.json>`:
150
+
151
+ ```json
152
+ {
153
+ "updateId": "batch-2",
154
+ "expectedRevision": 1,
155
+ "operation": "upsert",
156
+ "records": [
157
+ {
158
+ "id": "provider:next-id",
159
+ "name": "Another candidate",
160
+ "batchId": "batch-2"
161
+ }
162
+ ]
163
+ }
164
+ ```
165
+
166
+ Read the current view for its revision. Upsert appends new IDs and merges fields
167
+ for existing IDs while preserving decisions. Keep the same `updateId` AND body
168
+ when retrying an uncertain request. Reusing it with different data returns 409.
169
+ For a revision conflict, reread and reassess before issuing a new update. Never
170
+ replace the workspace to add a batch. The viewer shows 20 cards per page and a
171
+ View new notice for new IDs. Enrichment should not produce a new candidate.
172
+
173
+ ## Work with the shortlist
174
+
175
+ Read `GET /api/views/<id>/state` immediately before acting on a shortlist.
176
+ The UI uses Yes, Maybe, and No. `selectedIds` is Yes (the current shortlist);
177
+ `maybeIds` is Maybe; `removedIds` is No (set-aside people). The default Unreviewed tab contains
178
+ people in none of those lists. All includes every candidate, including removed ones.
179
+ Read `GET /api/views/<id>` for their records. Do not infer selections from earlier
180
+ chat messages. If the user explicitly requests a decision change, PUT the full
181
+ state to `/api/views/<id>/state`, preserving unrelated IDs and the legacy
182
+ `filter`, `sortBy`, and `sortDirection` fields. A candidate can be in only one of the three decision lists. Move them by updating
183
+ all three arrays in the same request, preserving unrelated decisions. Existing files
184
+ without `maybeIds` load with an empty list; omitting `maybeIds` or `removedIds`
185
+ in an older request preserves that array's current value.
186
+ This single-user preview uses last-write-wins decisions; reread before changes.
187
+
188
+ When asked to draft outreach, upsert `draftSubject` and `draftEmail` on the
189
+ shortlisted records. These appear in profile details and persist across restarts.
190
+ Profiles separate Overview, Career, and Contact, hiding empty sections. Drafts
191
+ appear in Contact. Keep `fitSummary` and `unconfirmed` concise; longer reasoning
192
+ goes in `justification` and `gaps` behind Read full assessment.
193
+ Drafting is not permission to send, reveal paid contacts, or verify emails.
194
+ Use the existing ScoutMesh outreach and cost guidance for those separate steps.
195
+
196
+ Limits: 20 workspaces, 2,000 records per workspace, 2 MB per request. Split large
197
+ batches into separate updates with distinct IDs. Live browser updates use SSE;
198
+ missed events are recovered by fetching the latest view on reconnect. Pagination
199
+ limits rendered cards; the API still returns a full workspace. Browser page
200
+ position, open profiles, Undo, and View new notices reset on reload.