@cloudgrid-io/mcp 0.20.6 → 0.20.8

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.
Files changed (33) hide show
  1. package/README.md +40 -31
  2. package/package.json +2 -1
  3. package/src/corpus/capability-map.md +21 -13
  4. package/src/corpus/cloudgrid-yaml.md +5 -3
  5. package/src/corpus/examples/ai-app/index.md +4 -3
  6. package/src/corpus/examples/docs-app/index.md +113 -0
  7. package/src/corpus/templates/ai-app/README.md +4 -4
  8. package/src/corpus/templates/ai-app/index.md +4 -4
  9. package/src/corpus/templates/ai-knowledge-base/AGENTS.md +17 -17
  10. package/src/corpus/templates/ai-knowledge-base/README.md +5 -4
  11. package/src/corpus/templates/ai-knowledge-base/cloudgrid.yaml +5 -4
  12. package/src/corpus/templates/ai-knowledge-base/index.md +15 -11
  13. package/src/corpus/templates/semantic-search/AGENTS.md +9 -9
  14. package/src/corpus/templates/semantic-search/README.md +4 -3
  15. package/src/corpus/templates/semantic-search/cloudgrid.yaml +6 -5
  16. package/src/corpus/templates/semantic-search/index.md +7 -4
  17. package/src/corpus/templates/simple-semantic-search/README.md +20 -0
  18. package/src/corpus/templates/simple-semantic-search/cloudgrid.yaml +235 -0
  19. package/src/corpus/templates/simple-semantic-search/index.md +353 -0
  20. package/src/corpus/templates/simple-semantic-search/services/web/package.json +15 -0
  21. package/src/corpus/templates/simple-semantic-search/services/web/src/document.txt +40 -0
  22. package/src/corpus/templates/simple-semantic-search/services/web/src/index.js +225 -0
  23. package/src/corpus/workflows/ai-app.md +5 -4
  24. package/src/corpus/workflows/ai-knowledge-base.md +17 -14
  25. package/src/corpus/workflows/semantic-search.md +9 -7
  26. package/src/corpus/workflows/simple-semantic-search.md +64 -0
  27. package/src/playbook.js +135 -0
  28. package/src/tools/cli.js +446 -0
  29. package/src/tools/constants.js +106 -0
  30. package/src/tools/deploy.js +1677 -0
  31. package/src/tools/register.js +1165 -0
  32. package/src/tools/util.js +17 -0
  33. package/src/tools.js +30 -3470
@@ -0,0 +1,353 @@
1
+ # Template: simple-semantic-search (single-service pgvector search)
2
+
3
+ Semantic search over a document in ONE node service — the smallest real vector
4
+ app on the grid. The platform provisions pgvector from `needs: { vector:
5
+ pgvector }` and injects `VECTOR_PGVECTOR_URL`; embeddings come from the AI
6
+ Gateway via `@cloudgrid-io/runtime` (`needs: { ai: true }`, no API key). Proven
7
+ by a real end-to-end deploy (live in under 3 minutes, 2026-07-16).
8
+
9
+ For a multi-service search portfolio (upload UI, separate refresh cron), use the
10
+ heavier `semantic-search` blueprint instead. This template is the default for
11
+ "search my document by meaning".
12
+
13
+ **Key rules (all proven by the live deploy):**
14
+
15
+ 1. **Size the vector column from a probe, never hardcode the dimension.** Embed
16
+ one chunk first (`runtime.ai.embeddings`), read `probe.dim`, and create the
17
+ table with `vector(${dim})`. Hardcoding 1536 breaks when the platform model
18
+ differs.
19
+ 2. **Seed in the background; report readiness via `/health`.** Bind the HTTP
20
+ port immediately (the platform startup probe wants it open), run the
21
+ embed+insert seed async, and return 503 from `/health` until it finishes —
22
+ the readiness probe then holds the pod out of traffic until search works.
23
+ The seed is idempotent (`CREATE ... IF NOT EXISTS`, count check, upsert).
24
+ 3. **Read `VECTOR_PGVECTOR_URL` lazily inside the connect function** (with the
25
+ legacy `PGVECTOR_URL` fallback). Never at module top level, never hardcoded.
26
+ 4. **Query with the pgvector cosine operator.** `ORDER BY embedding <=> $1::vector`
27
+ with score `1 - (embedding <=> $1::vector)`. Pass vectors as literals:
28
+ `'[0.1,0.2,...]'` + `::vector` cast.
29
+ 5. **Service code lives under `services/web/`**, not the project root.
30
+
31
+ ## File tree
32
+
33
+ ```
34
+ cloudgrid.yaml # name + needs: { vector: pgvector, ai: true } + services.web (node, health: /health)
35
+ services/web/package.json # @cloudgrid-io/runtime, express, pg — nothing else
36
+ services/web/src/index.js # seed + /search + minimal UI (single file)
37
+ services/web/src/document.txt # the content to index — replace with the user's document
38
+ ```
39
+
40
+ ## What the CLI writes for you: `.cloudgrid/link.json`
41
+
42
+ After `grid init app <name> --here` (or the first `grid plug`), the CLI binds
43
+ the folder to its grid entity by writing `.cloudgrid/link.json`. It looks like:
44
+
45
+ ```json
46
+ {
47
+ "entity_id": "923b2d46-ce6e-42e5-9976-311f8dab5755",
48
+ "org_id": "602fbff7-920b-4221-afb4-7440915b6bab",
49
+ "org_slug": "atomic",
50
+ "kind": "app",
51
+ "entity_slug": "simple-semantic-search-86f2",
52
+ "created_at": "2026-07-16T13:28:32.744Z",
53
+ "cli_version": "0.15.4"
54
+ }
55
+ ```
56
+
57
+ **Never hand-author or copy this file** — the CLI writes it, and every value is
58
+ minted at init time (the slug gets a 4-hex suffix). Two practical consequences:
59
+
60
+ - A folder **with** `.cloudgrid/link.json` is bound: `grid plug` UPDATES that
61
+ entity. To fork someone else's project into your own app, `rm -rf .cloudgrid`
62
+ (and remove any `name:` you don't own from `cloudgrid.yaml`), then
63
+ `grid init app <your-name> --here` for a fresh identity.
64
+ - `link.json` is why identity does NOT belong in `cloudgrid.yaml` — the
65
+ manifest describes the app; the link binds the folder.
66
+
67
+ ## Deploy
68
+
69
+ ```bash
70
+ grid init app <name> --here # once, registers the entity + writes .cloudgrid/link.json
71
+ grid plug # build + deploy, async — live URL in ~2-3 min
72
+ grid visibility set <slug> link # default is grid-only (403 outside the org)
73
+ ```
74
+
75
+ Then open the URL: a search box over the document, ranked by embedding
76
+ similarity. Replace `services/web/src/document.txt` with the user's content and
77
+ re-run `grid plug` to re-seed (delete rows or bump the table if the document
78
+ shrinks — the seed only tops up).
79
+
80
+ ## cloudgrid.yaml (active fields)
81
+
82
+ ```yaml
83
+ name: simple-semantic-search
84
+ description: Semantic search over a bundled document, backed by pgvector.
85
+ icon: "🔎"
86
+
87
+ needs:
88
+ vector: pgvector # injects VECTOR_PGVECTOR_URL (+legacy PGVECTOR_URL)
89
+ ai: true # embeddings via @cloudgrid-io/runtime, no API key
90
+
91
+ services:
92
+ web:
93
+ type: node
94
+ path: /
95
+ health: /health # 503 until the seed finishes
96
+ ```
97
+
98
+ ## services/web/package.json
99
+
100
+ ```json
101
+ {
102
+ "name": "simple-semantic-search-web",
103
+ "version": "1.0.0",
104
+ "private": true,
105
+ "description": "Semantic search over a bundled document on the CloudGrid vector substrate (pgvector).",
106
+ "main": "src/index.js",
107
+ "scripts": {
108
+ "start": "node src/index.js"
109
+ },
110
+ "dependencies": {
111
+ "@cloudgrid-io/runtime": "^1.0.3",
112
+ "express": "^4.21.2",
113
+ "pg": "^8.13.0"
114
+ }
115
+ }
116
+ ```
117
+
118
+ ## services/web/src/index.js
119
+
120
+ ```js
121
+ 'use strict';
122
+
123
+ // Simple semantic search — single-service reference for the CloudGrid managed vector
124
+ // substrate (pgvector) + the platform embeddings endpoint.
125
+ //
126
+ // On startup it embeds the bundled document (one vector per passage)
127
+ // via runtime.ai.embeddings and stores the vectors in the entity's own pgvector
128
+ // schema. POST /search embeds the query and returns the nearest passages by
129
+ // cosine distance. No keyword matching — relevance comes entirely from the
130
+ // embeddings.
131
+
132
+ const fs = require('fs');
133
+ const path = require('path');
134
+ const express = require('express');
135
+ const { Client } = require('pg');
136
+ const { runtime } = require('@cloudgrid-io/runtime');
137
+
138
+ const PORT = process.env.PORT || 8080;
139
+ const PGVECTOR_URL = process.env.VECTOR_PGVECTOR_URL || process.env.PGVECTOR_URL;
140
+
141
+ // One passage per non-empty paragraph in the article.
142
+ function loadChunks() {
143
+ const raw = fs.readFileSync(path.join(__dirname, 'document.txt'), 'utf8');
144
+ return raw
145
+ .split(/\n\s*\n/)
146
+ .map((p) => p.replace(/\s+/g, ' ').trim())
147
+ .filter((p) => p.length > 0);
148
+ }
149
+
150
+ // Format a JS number[] as a pgvector literal: '[0.1,0.2,...]'.
151
+ function toVectorLiteral(values) {
152
+ return '[' + values.join(',') + ']';
153
+ }
154
+
155
+ let seeded = false;
156
+ let seedError = null;
157
+ let chunkCount = 0;
158
+ let embeddingDim = 0;
159
+
160
+ async function connect() {
161
+ if (!PGVECTOR_URL) {
162
+ throw new Error('VECTOR_PGVECTOR_URL is not set — the vector need did not provision');
163
+ }
164
+ const client = new Client({ connectionString: PGVECTOR_URL });
165
+ await client.connect();
166
+ return client;
167
+ }
168
+
169
+ // Idempotent seed: ensure the extension + table exist, sized to the embedding
170
+ // model's actual output length, and populate the table once.
171
+ async function seed() {
172
+ const chunks = loadChunks();
173
+ const client = await connect();
174
+ try {
175
+ await client.query('CREATE EXTENSION IF NOT EXISTS vector');
176
+
177
+ // Size the column to the model's real output length — embed a probe first.
178
+ const probe = await runtime.ai.embeddings({ text: chunks[0] });
179
+ embeddingDim = probe.dim;
180
+ console.log(`[seed] embedding model=${probe.model} dim=${embeddingDim}`);
181
+
182
+ await client.query(
183
+ `CREATE TABLE IF NOT EXISTS chunks (
184
+ id integer PRIMARY KEY,
185
+ chunk text NOT NULL,
186
+ embedding vector(${embeddingDim}) NOT NULL
187
+ )`,
188
+ );
189
+
190
+ const { rows } = await client.query('SELECT count(*)::int AS n FROM chunks');
191
+ const existing = rows[0].n;
192
+ if (existing >= chunks.length) {
193
+ chunkCount = existing;
194
+ console.log(`[seed] already populated with ${existing} chunks — skipping embed`);
195
+ seeded = true;
196
+ return;
197
+ }
198
+
199
+ console.log(`[seed] embedding ${chunks.length} chunks`);
200
+ // Batch-embed every passage in input order.
201
+ const vectors = await runtime.ai.embeddings({ text: chunks });
202
+ for (let i = 0; i < chunks.length; i++) {
203
+ const literal = toVectorLiteral(vectors[i].values);
204
+ await client.query(
205
+ `INSERT INTO chunks (id, chunk, embedding)
206
+ VALUES ($1, $2, $3::vector)
207
+ ON CONFLICT (id) DO UPDATE SET chunk = EXCLUDED.chunk, embedding = EXCLUDED.embedding`,
208
+ [i, chunks[i], literal],
209
+ );
210
+ console.log(`[seed] inserted chunk ${i + 1}/${chunks.length}`);
211
+ }
212
+ chunkCount = chunks.length;
213
+ seeded = true;
214
+ console.log(`[seed] done — ${chunkCount} chunks stored`);
215
+ } finally {
216
+ await client.end().catch(() => {});
217
+ }
218
+ }
219
+
220
+ async function search(query, limit = 5) {
221
+ const q = await runtime.ai.embeddings({ text: query });
222
+ const literal = toVectorLiteral(q.values);
223
+ const client = await connect();
224
+ try {
225
+ const { rows } = await client.query(
226
+ `SELECT chunk, 1 - (embedding <=> $1::vector) AS score
227
+ FROM chunks
228
+ ORDER BY embedding <=> $1::vector
229
+ LIMIT $2`,
230
+ [literal, limit],
231
+ );
232
+ return rows.map((r) => ({ chunk: r.chunk, score: Number(r.score) }));
233
+ } finally {
234
+ await client.end().catch(() => {});
235
+ }
236
+ }
237
+
238
+ const app = express();
239
+ app.use(express.json());
240
+
241
+ app.get('/health', (_req, res) => {
242
+ // Return not-ready until the seed completes so the platform's readiness probe
243
+ // holds the pod out of traffic until search actually works.
244
+ if (seedError) return res.status(500).json({ status: 'seed_failed', error: seedError });
245
+ if (!seeded) return res.status(503).json({ status: 'seeding' });
246
+ res.json({ status: 'ok', chunks: chunkCount, dim: embeddingDim });
247
+ });
248
+
249
+ app.post('/search', async (req, res) => {
250
+ const query = req.body && typeof req.body.query === 'string' ? req.body.query.trim() : '';
251
+ if (!query) return res.status(400).json({ error: 'Provide a query string.' });
252
+ if (!seeded) return res.status(503).json({ error: 'The index is still loading. Try again shortly.' });
253
+ try {
254
+ const results = await search(query);
255
+ res.json({ query, results });
256
+ } catch (err) {
257
+ console.error('[search] failed', err);
258
+ res.status(500).json({ error: 'Search failed.' });
259
+ }
260
+ });
261
+
262
+ app.get('/', (_req, res) => {
263
+ res.type('html').send(PAGE);
264
+ });
265
+
266
+ const PAGE = `<!doctype html>
267
+ <html lang="en">
268
+ <head>
269
+ <meta charset="utf-8" />
270
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
271
+ <title>Semantic search</title>
272
+ <style>
273
+ :root { color-scheme: light; }
274
+ * { box-sizing: border-box; }
275
+ body { margin: 0; font: 16px/1.5 -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; color: #1a1a1a; background: #fafafa; }
276
+ main { max-width: 680px; margin: 0 auto; padding: 48px 20px 80px; }
277
+ h1 { font-size: 24px; margin: 0 0 4px; }
278
+ p.lede { color: #555; margin: 0 0 28px; }
279
+ form { display: flex; gap: 8px; margin-bottom: 24px; }
280
+ input { flex: 1; padding: 12px 14px; font-size: 16px; border: 1px solid #ccc; border-radius: 8px; background: #fff; }
281
+ input:focus { outline: none; border-color: #888; }
282
+ button { padding: 12px 20px; font-size: 16px; border: none; border-radius: 8px; background: #1a1a1a; color: #fff; cursor: pointer; }
283
+ button:disabled { opacity: .5; cursor: default; }
284
+ .result { padding: 16px 0; border-top: 1px solid #eee; }
285
+ .result .score { font-size: 13px; color: #888; margin-bottom: 4px; font-variant-numeric: tabular-nums; }
286
+ .result .text { margin: 0; }
287
+ .status { color: #888; font-size: 14px; }
288
+ </style>
289
+ </head>
290
+ <body>
291
+ <main>
292
+ <h1>Semantic search</h1>
293
+ <p class="lede">Search the document by meaning. Results are ranked by embedding similarity, not keyword match.</p>
294
+ <form id="f">
295
+ <input id="q" type="text" placeholder="Ask a question about the document" autocomplete="off" />
296
+ <button id="b" type="submit">Search</button>
297
+ </form>
298
+ <div id="out"></div>
299
+ </main>
300
+ <script>
301
+ var f = document.getElementById('f');
302
+ var q = document.getElementById('q');
303
+ var b = document.getElementById('b');
304
+ var out = document.getElementById('out');
305
+ f.addEventListener('submit', function (e) {
306
+ e.preventDefault();
307
+ var query = q.value.trim();
308
+ if (!query) return;
309
+ b.disabled = true;
310
+ out.innerHTML = '<p class="status">Searching.</p>';
311
+ fetch('/search', {
312
+ method: 'POST',
313
+ headers: { 'Content-Type': 'application/json' },
314
+ body: JSON.stringify({ query: query }),
315
+ })
316
+ .then(function (r) { return r.json(); })
317
+ .then(function (data) {
318
+ b.disabled = false;
319
+ if (!data.results || !data.results.length) {
320
+ out.innerHTML = '<p class="status">No results.</p>';
321
+ return;
322
+ }
323
+ out.innerHTML = data.results.map(function (r) {
324
+ return '<div class="result"><div class="score">score ' + r.score.toFixed(3) +
325
+ '</div><p class="text">' + r.chunk.replace(/</g, '&lt;') + '</p></div>';
326
+ }).join('');
327
+ })
328
+ .catch(function () {
329
+ b.disabled = false;
330
+ out.innerHTML = '<p class="status">Search failed. Try again.</p>';
331
+ });
332
+ });
333
+ </script>
334
+ </body>
335
+ </html>`;
336
+
337
+ app.listen(PORT, () => {
338
+ console.log(`simple-semantic-search listening on ${PORT}`);
339
+ // Seed in the background so the HTTP server binds immediately (the platform's
340
+ // startup probe wants the port open). /health reports seeding progress.
341
+ seed().catch((err) => {
342
+ seedError = String((err && err.message) || err);
343
+ console.error('[seed] FAILED', err);
344
+ });
345
+ });
346
+ ```
347
+
348
+ ## services/web/src/document.txt
349
+
350
+ Any plain-text document, one passage per blank-line-separated paragraph. The
351
+ template ships a short factual solar-system article so the demo answers
352
+ questions like "which planet is the hottest?" — replace it with the user's
353
+ content.
@@ -0,0 +1,15 @@
1
+ {
2
+ "name": "simple-semantic-search-web",
3
+ "version": "1.0.0",
4
+ "private": true,
5
+ "description": "Semantic search over a bundled document on the CloudGrid vector substrate (pgvector).",
6
+ "main": "src/index.js",
7
+ "scripts": {
8
+ "start": "node src/index.js"
9
+ },
10
+ "dependencies": {
11
+ "@cloudgrid-io/runtime": "^1.0.3",
12
+ "express": "^4.21.2",
13
+ "pg": "^8.13.0"
14
+ }
15
+ }
@@ -0,0 +1,40 @@
1
+ The solar system formed about 4.6 billion years ago from the gravitational
2
+ collapse of a giant molecular cloud. Most of the collapsing mass gathered at
3
+ the center to form the Sun, while the rest flattened into a disk from which
4
+ the planets, moons, and asteroids emerged.
5
+
6
+ Mercury is the smallest planet and the closest to the Sun. Despite that, it is
7
+ not the hottest: it has almost no atmosphere to trap heat, so its night side
8
+ plunges to minus 180 degrees Celsius.
9
+
10
+ Venus is the hottest planet. Its thick carbon-dioxide atmosphere creates a
11
+ runaway greenhouse effect that keeps the surface near 465 degrees Celsius, hot
12
+ enough to melt lead, day and night alike.
13
+
14
+ Earth is the only planet known to support life. Liquid water covers about 71
15
+ percent of its surface, and its magnetic field shields the atmosphere from the
16
+ solar wind.
17
+
18
+ Mars is a cold desert world with the largest volcano in the solar system,
19
+ Olympus Mons, which stands about three times the height of Mount Everest.
20
+ Evidence suggests liquid water flowed on Mars billions of years ago.
21
+
22
+ Jupiter is the largest planet, with more than twice the mass of all the other
23
+ planets combined. Its Great Red Spot is a storm larger than Earth that has
24
+ raged for at least 300 years.
25
+
26
+ Saturn is famous for its rings, which are made mostly of water ice. The rings
27
+ span up to 282,000 kilometers across but are on average only about 10 meters
28
+ thick.
29
+
30
+ Uranus rotates on its side, with an axial tilt of about 98 degrees, probably
31
+ the result of a giant collision early in its history. Its seasons each last
32
+ about 21 Earth years.
33
+
34
+ Neptune is the windiest planet, with gusts reaching 2,100 kilometers per hour.
35
+ It was the first planet located through mathematical prediction rather than
36
+ direct observation.
37
+
38
+ Pluto, reclassified as a dwarf planet in 2006, orbits in the Kuiper Belt. It
39
+ has five known moons, and its largest, Charon, is so big that the two bodies
40
+ orbit a point in space between them.
@@ -0,0 +1,225 @@
1
+ 'use strict';
2
+
3
+ // Simple semantic search — single-service reference for the CloudGrid managed vector
4
+ // substrate (pgvector) + the platform embeddings endpoint.
5
+ //
6
+ // On startup it embeds the bundled document (one vector per passage)
7
+ // via runtime.ai.embeddings and stores the vectors in the entity's own pgvector
8
+ // schema. POST /search embeds the query and returns the nearest passages by
9
+ // cosine distance. No keyword matching — relevance comes entirely from the
10
+ // embeddings.
11
+
12
+ const fs = require('fs');
13
+ const path = require('path');
14
+ const express = require('express');
15
+ const { Client } = require('pg');
16
+ const { runtime } = require('@cloudgrid-io/runtime');
17
+
18
+ const PORT = process.env.PORT || 8080;
19
+ const PGVECTOR_URL = process.env.VECTOR_PGVECTOR_URL || process.env.PGVECTOR_URL;
20
+
21
+ // One passage per non-empty paragraph in the article.
22
+ function loadChunks() {
23
+ const raw = fs.readFileSync(path.join(__dirname, 'document.txt'), 'utf8');
24
+ return raw
25
+ .split(/\n\s*\n/)
26
+ .map((p) => p.replace(/\s+/g, ' ').trim())
27
+ .filter((p) => p.length > 0);
28
+ }
29
+
30
+ // Format a JS number[] as a pgvector literal: '[0.1,0.2,...]'.
31
+ function toVectorLiteral(values) {
32
+ return '[' + values.join(',') + ']';
33
+ }
34
+
35
+ let seeded = false;
36
+ let seedError = null;
37
+ let chunkCount = 0;
38
+ let embeddingDim = 0;
39
+
40
+ async function connect() {
41
+ if (!PGVECTOR_URL) {
42
+ throw new Error('VECTOR_PGVECTOR_URL is not set — the vector need did not provision');
43
+ }
44
+ const client = new Client({ connectionString: PGVECTOR_URL });
45
+ await client.connect();
46
+ return client;
47
+ }
48
+
49
+ // Idempotent seed: ensure the extension + table exist, sized to the embedding
50
+ // model's actual output length, and populate the table once.
51
+ async function seed() {
52
+ const chunks = loadChunks();
53
+ const client = await connect();
54
+ try {
55
+ await client.query('CREATE EXTENSION IF NOT EXISTS vector');
56
+
57
+ // Size the column to the model's real output length — embed a probe first.
58
+ const probe = await runtime.ai.embeddings({ text: chunks[0] });
59
+ embeddingDim = probe.dim;
60
+ console.log(`[seed] embedding model=${probe.model} dim=${embeddingDim}`);
61
+
62
+ await client.query(
63
+ `CREATE TABLE IF NOT EXISTS chunks (
64
+ id integer PRIMARY KEY,
65
+ chunk text NOT NULL,
66
+ embedding vector(${embeddingDim}) NOT NULL
67
+ )`,
68
+ );
69
+
70
+ const { rows } = await client.query('SELECT count(*)::int AS n FROM chunks');
71
+ const existing = rows[0].n;
72
+ if (existing >= chunks.length) {
73
+ chunkCount = existing;
74
+ console.log(`[seed] already populated with ${existing} chunks — skipping embed`);
75
+ seeded = true;
76
+ return;
77
+ }
78
+
79
+ console.log(`[seed] embedding ${chunks.length} chunks`);
80
+ // Batch-embed every passage in input order.
81
+ const vectors = await runtime.ai.embeddings({ text: chunks });
82
+ for (let i = 0; i < chunks.length; i++) {
83
+ const literal = toVectorLiteral(vectors[i].values);
84
+ await client.query(
85
+ `INSERT INTO chunks (id, chunk, embedding)
86
+ VALUES ($1, $2, $3::vector)
87
+ ON CONFLICT (id) DO UPDATE SET chunk = EXCLUDED.chunk, embedding = EXCLUDED.embedding`,
88
+ [i, chunks[i], literal],
89
+ );
90
+ console.log(`[seed] inserted chunk ${i + 1}/${chunks.length}`);
91
+ }
92
+ chunkCount = chunks.length;
93
+ seeded = true;
94
+ console.log(`[seed] done — ${chunkCount} chunks stored`);
95
+ } finally {
96
+ await client.end().catch(() => {});
97
+ }
98
+ }
99
+
100
+ async function search(query, limit = 5) {
101
+ const q = await runtime.ai.embeddings({ text: query });
102
+ const literal = toVectorLiteral(q.values);
103
+ const client = await connect();
104
+ try {
105
+ const { rows } = await client.query(
106
+ `SELECT chunk, 1 - (embedding <=> $1::vector) AS score
107
+ FROM chunks
108
+ ORDER BY embedding <=> $1::vector
109
+ LIMIT $2`,
110
+ [literal, limit],
111
+ );
112
+ return rows.map((r) => ({ chunk: r.chunk, score: Number(r.score) }));
113
+ } finally {
114
+ await client.end().catch(() => {});
115
+ }
116
+ }
117
+
118
+ const app = express();
119
+ app.use(express.json());
120
+
121
+ app.get('/health', (_req, res) => {
122
+ // Return not-ready until the seed completes so the platform's readiness probe
123
+ // holds the pod out of traffic until search actually works.
124
+ if (seedError) return res.status(500).json({ status: 'seed_failed', error: seedError });
125
+ if (!seeded) return res.status(503).json({ status: 'seeding' });
126
+ res.json({ status: 'ok', chunks: chunkCount, dim: embeddingDim });
127
+ });
128
+
129
+ app.post('/search', async (req, res) => {
130
+ const query = req.body && typeof req.body.query === 'string' ? req.body.query.trim() : '';
131
+ if (!query) return res.status(400).json({ error: 'Provide a query string.' });
132
+ if (!seeded) return res.status(503).json({ error: 'The index is still loading. Try again shortly.' });
133
+ try {
134
+ const results = await search(query);
135
+ res.json({ query, results });
136
+ } catch (err) {
137
+ console.error('[search] failed', err);
138
+ res.status(500).json({ error: 'Search failed.' });
139
+ }
140
+ });
141
+
142
+ app.get('/', (_req, res) => {
143
+ res.type('html').send(PAGE);
144
+ });
145
+
146
+ const PAGE = `<!doctype html>
147
+ <html lang="en">
148
+ <head>
149
+ <meta charset="utf-8" />
150
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
151
+ <title>Semantic search</title>
152
+ <style>
153
+ :root { color-scheme: light; }
154
+ * { box-sizing: border-box; }
155
+ body { margin: 0; font: 16px/1.5 -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; color: #1a1a1a; background: #fafafa; }
156
+ main { max-width: 680px; margin: 0 auto; padding: 48px 20px 80px; }
157
+ h1 { font-size: 24px; margin: 0 0 4px; }
158
+ p.lede { color: #555; margin: 0 0 28px; }
159
+ form { display: flex; gap: 8px; margin-bottom: 24px; }
160
+ input { flex: 1; padding: 12px 14px; font-size: 16px; border: 1px solid #ccc; border-radius: 8px; background: #fff; }
161
+ input:focus { outline: none; border-color: #888; }
162
+ button { padding: 12px 20px; font-size: 16px; border: none; border-radius: 8px; background: #1a1a1a; color: #fff; cursor: pointer; }
163
+ button:disabled { opacity: .5; cursor: default; }
164
+ .result { padding: 16px 0; border-top: 1px solid #eee; }
165
+ .result .score { font-size: 13px; color: #888; margin-bottom: 4px; font-variant-numeric: tabular-nums; }
166
+ .result .text { margin: 0; }
167
+ .status { color: #888; font-size: 14px; }
168
+ </style>
169
+ </head>
170
+ <body>
171
+ <main>
172
+ <h1>Semantic search</h1>
173
+ <p class="lede">Search the document by meaning. Results are ranked by embedding similarity, not keyword match.</p>
174
+ <form id="f">
175
+ <input id="q" type="text" placeholder="Ask a question about the document" autocomplete="off" />
176
+ <button id="b" type="submit">Search</button>
177
+ </form>
178
+ <div id="out"></div>
179
+ </main>
180
+ <script>
181
+ var f = document.getElementById('f');
182
+ var q = document.getElementById('q');
183
+ var b = document.getElementById('b');
184
+ var out = document.getElementById('out');
185
+ f.addEventListener('submit', function (e) {
186
+ e.preventDefault();
187
+ var query = q.value.trim();
188
+ if (!query) return;
189
+ b.disabled = true;
190
+ out.innerHTML = '<p class="status">Searching.</p>';
191
+ fetch('/search', {
192
+ method: 'POST',
193
+ headers: { 'Content-Type': 'application/json' },
194
+ body: JSON.stringify({ query: query }),
195
+ })
196
+ .then(function (r) { return r.json(); })
197
+ .then(function (data) {
198
+ b.disabled = false;
199
+ if (!data.results || !data.results.length) {
200
+ out.innerHTML = '<p class="status">No results.</p>';
201
+ return;
202
+ }
203
+ out.innerHTML = data.results.map(function (r) {
204
+ return '<div class="result"><div class="score">score ' + r.score.toFixed(3) +
205
+ '</div><p class="text">' + r.chunk.replace(/</g, '&lt;') + '</p></div>';
206
+ }).join('');
207
+ })
208
+ .catch(function () {
209
+ b.disabled = false;
210
+ out.innerHTML = '<p class="status">Search failed. Try again.</p>';
211
+ });
212
+ });
213
+ </script>
214
+ </body>
215
+ </html>`;
216
+
217
+ app.listen(PORT, () => {
218
+ console.log(`simple-semantic-search listening on ${PORT}`);
219
+ // Seed in the background so the HTTP server binds immediately (the platform's
220
+ // startup probe wants the port open). /health reports seeding progress.
221
+ seed().catch((err) => {
222
+ seedError = String((err && err.message) || err);
223
+ console.error('[seed] FAILED', err);
224
+ });
225
+ });
@@ -4,7 +4,7 @@ when: chatbot, AI assistant, Q&A bot, conversational app, support bot, ask-me-an
4
4
  needs: ai, database
5
5
  deploy: runtime
6
6
  editions: local
7
- capabilities_note: AI chatbot — needs ai (the grid AI gateway) + a database (Mongo) for chat history. Next.js App Router app. Uses @cloudgrid-io/ai createClient().chat() with zero-config in-grid identity auth (no API key). Runtime app, async build, local edition only. Declare `needs: { ai: true, database: true }`. (RAG over your own docs needs `needs: { vector: pgvector }` — not yet available, platform issue #1545.)
7
+ capabilities_note: AI chatbot — needs ai (the grid AI gateway) + a database (Mongo) for chat history. Next.js App Router app. Uses @cloudgrid-io/ai createClient().chat() with zero-config in-grid identity auth (no API key). Runtime app, async build, local edition only. Declare `needs: { ai: true, database: true }`. (RAG over your own docs needs `needs: { vector: pgvector }` — now available, #1545 shipped (verified live 2026-07-16); this template ships the plain chatbot without it.)
8
8
  summary: Build an AI chatbot on the grid — a Next.js app that calls @cloudgrid-io/ai createClient().chat({messages}) (zero-config in-grid auth, no key) and persists the conversation to grid-shared Mongo. Edition-gate first, scaffold, put the app under services/web/, declare needs:{ai:true,database:true}, read DATABASE_MONGODB_URL lazily, deploy async, poll to a live URL.
9
9
  ---
10
10
 
@@ -66,7 +66,7 @@ ai: true, database: true }`).
66
66
  ai: true
67
67
  database: true
68
68
  # For retrieval-augmented (RAG) over your own docs, add `needs: { vector:
69
- # pgvector }` once platform issue #1545 ships — not yet available.
69
+ # pgvector }` — available now (#1545 shipped); this template does not use it.
70
70
  ```
71
71
  **Declare `needs: { ai: true, database: true }`** — the canonical shape. The
72
72
  deployer wires the AI gateway (in-grid identity) and provisions Mongo,
@@ -129,8 +129,9 @@ the SAME entity (`target_entity_id`) so it updates the same URL.
129
129
 
130
130
  If the user wants the bot to answer over **their own documents** (retrieval-
131
131
  augmented generation), that needs a vector store (`needs: { vector: pgvector }`).
132
- That path is **not yet available** — platform issue #1545. Ship the plain
133
- chatbot now; add retrieval once the vector need is unblocked.
132
+ That path is **now available** — #1545 shipped. The template itself is a plain
133
+ chatbot; declaring `vector: pgvector` injects `VECTOR_PGVECTOR_URL`, and you
134
+ build the retrieval code on top of it.
134
135
 
135
136
  Keep it honest: async build, local-edition only, AI auth and DB credentials
136
137
  provided by the grid.