@construct-space/cli 1.7.6 → 1.8.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.
package/dist/index.js CHANGED
@@ -5312,6 +5312,7 @@ async function scaffold(nameArg, options) {
5312
5312
  const files = {
5313
5313
  "package.json.tmpl": join2(name, "package.json"),
5314
5314
  "vite.config.ts.tmpl": join2(name, "vite.config.ts"),
5315
+ "style.css.tmpl": join2(name, "src", "style.css"),
5315
5316
  "index.vue.tmpl": join2(name, "src", "pages", "index.vue"),
5316
5317
  "config.md.tmpl": join2(name, "agent", "config.md"),
5317
5318
  "skill.md.tmpl": join2(name, "agent", "skills", "default.md"),
@@ -8154,6 +8155,9 @@ function generate(root, m) {
8154
8155
  "// Auto-generated entry \u2014 do not edit manually",
8155
8156
  "// Generated from space.manifest.json"
8156
8157
  ];
8158
+ if (existsSync4(join4(root, "src", "style.css"))) {
8159
+ lines.push("import './style.css'");
8160
+ }
8157
8161
  for (const p of pages)
8158
8162
  lines.push(`import ${p.varName} from '${p.importPath}'`);
8159
8163
  for (const w of widgets)
@@ -8490,6 +8494,13 @@ async function build(options) {
8490
8494
  renameSync(oldCSS, newCSS);
8491
8495
  }
8492
8496
  }
8497
+ const expectedCSS = join6(distDir, `space-${m.id}.css`);
8498
+ if (!existsSync6(expectedCSS)) {
8499
+ const cssMatches = readdirSync3(distDir).filter((f) => f.endsWith(".css"));
8500
+ const renamedCSS = cssMatches.find((f) => f.startsWith("space-")) || (cssMatches.length === 1 ? cssMatches[0] : undefined);
8501
+ if (renamedCSS)
8502
+ renameSync(join6(distDir, renamedCSS), expectedCSS);
8503
+ }
8493
8504
  const bundleData = readFileSync4(bundlePath);
8494
8505
  const checksum = createHash("sha256").update(bundleData).digest("hex");
8495
8506
  const raw = readRaw(root);
@@ -10337,7 +10348,10 @@ async function uploadSource(portalURL, identityToken, publisherKey, tarballPath,
10337
10348
  }
10338
10349
  msg += `
10339
10350
  Fork to a new space_id to publish your own version.`;
10340
- throw new Error(msg);
10351
+ const err = new Error(msg);
10352
+ err.ownerKind = result.owner_kind;
10353
+ err.ownerOrgId = result.owner_org_id;
10354
+ throw err;
10341
10355
  }
10342
10356
  if (resp.status >= 400) {
10343
10357
  const msg = result.error || result.errors?.join("; ") || `server returned ${resp.status}`;
@@ -10345,6 +10359,20 @@ async function uploadSource(portalURL, identityToken, publisherKey, tarballPath,
10345
10359
  }
10346
10360
  return result;
10347
10361
  }
10362
+ async function fetchOrgPublisherKey(portalURL, identityToken) {
10363
+ const root = portalURL.replace(/\/api\/developer\/?$/, "").replace(/\/+$/, "");
10364
+ try {
10365
+ const resp = await fetch(`${root}/api/publisher/org`, {
10366
+ headers: { Authorization: `Bearer ${identityToken}` }
10367
+ });
10368
+ if (!resp.ok)
10369
+ return null;
10370
+ const body = await resp.json();
10371
+ return body.publisher?.api_key || null;
10372
+ } catch {
10373
+ return null;
10374
+ }
10375
+ }
10348
10376
  function setVersionInFiles(root, oldVer, newVer) {
10349
10377
  const oldStr = `"version": "${oldVer}"`;
10350
10378
  const newStr = `"version": "${newVer}"`;
@@ -10501,7 +10529,23 @@ async function publish(options) {
10501
10529
  }
10502
10530
  const uploadSpinner = ora("Uploading & building...").start();
10503
10531
  try {
10504
- const result = await uploadSource(creds.portal, creds.token, creds.publisherKey, tarballPath, m, { private: wantPrivate, public: wantPublic });
10532
+ const explicitKey = options?.apiKey || process.env.CONSTRUCT_PUBLISHER_KEY;
10533
+ const initialKey = explicitKey || creds.publisherKey;
10534
+ let result;
10535
+ try {
10536
+ result = await uploadSource(creds.portal, creds.token, initialKey, tarballPath, m, { private: wantPrivate, public: wantPublic });
10537
+ } catch (e) {
10538
+ if (e?.ownerKind === "org" && !creds.publisherKey) {
10539
+ uploadSpinner.text = "Fetching org publisher key\u2026";
10540
+ const orgKey = await fetchOrgPublisherKey(creds.portal, creds.token);
10541
+ if (!orgKey)
10542
+ throw e;
10543
+ uploadSpinner.text = "Uploading & building (as org)\u2026";
10544
+ result = await uploadSource(creds.portal, creds.token, orgKey, tarballPath, m, { private: wantPrivate, public: wantPublic });
10545
+ } else {
10546
+ throw e;
10547
+ }
10548
+ }
10505
10549
  unlinkSync2(tarballPath);
10506
10550
  gitSafe(root, "tag", `v${m.version}`);
10507
10551
  gitSafe(root, "push", "origin", `v${m.version}`);
@@ -11457,7 +11501,7 @@ function graphFork(newSpaceID) {
11457
11501
  // package.json
11458
11502
  var package_default = {
11459
11503
  name: "@construct-space/cli",
11460
- version: "1.7.6",
11504
+ version: "1.8.0",
11461
11505
  description: "Construct CLI \u2014 scaffold, build, develop, and publish spaces",
11462
11506
  type: "module",
11463
11507
  bin: {
@@ -11509,7 +11553,7 @@ program2.command("scaffold [name]").alias("new").alias("create").description("Cr
11509
11553
  program2.command("build").description("Build the space (generate entry + run Vite)").option("--entry-only", "Only generate src/entry.ts").action(async (opts) => build(opts));
11510
11554
  program2.command("dev").description("Start dev mode with file watching and live reload").action(async () => dev());
11511
11555
  program2.command("install").alias("run").description("Install built space to Construct spaces directory").action(() => install());
11512
- program2.command("publish").description("Publish a space to the Construct registry").option("-y, --yes", "Skip all confirmation prompts").option("--bump <type>", "Auto-bump version (patch, minor, major)").option("--private", "Publish as org-private (catalog-listed only inside the owning org). Requires an org publisher key.").option("--public", "Flip a previously-private space back to the public catalog on this publish. Without --private or --public, visibility is preserved on update (and defaults to public on first publish).").action(async (opts) => publish(opts));
11556
+ program2.command("publish").description("Publish a space to the Construct registry").option("-y, --yes", "Skip all confirmation prompts").option("--bump <type>", "Auto-bump version (patch, minor, major)").option("--private", "Publish as org-private (catalog-listed only inside the owning org). Requires an org publisher key.").option("--public", "Flip a previously-private space back to the public catalog on this publish. Without --private or --public, visibility is preserved on update (and defaults to public on first publish).").option("--api-key <key>", "Publisher API key (csk_live_\u2026). Overrides any key stored in the active profile. Also reads CONSTRUCT_PUBLISHER_KEY.").action(async (opts) => publish(opts));
11513
11557
  program2.command("validate").description("Validate space.manifest.json").action(() => validate3());
11514
11558
  program2.command("check").description("Run type-check (vue-tsc) and linter (eslint)").action(() => check());
11515
11559
  program2.command("clean").description("Remove build artifacts").option("--all", "Also remove node_modules and lockfiles").action((opts) => clean(opts));
@@ -11540,7 +11584,7 @@ space.command("scaffold [name]").alias("new").alias("create").option("--with-tes
11540
11584
  space.command("build").option("--entry-only").action(async (opts) => build(opts));
11541
11585
  space.command("dev").action(async () => dev());
11542
11586
  space.command("install").alias("run").action(() => install());
11543
- space.command("publish").option("-y, --yes").option("--bump <type>").option("--private").option("--public").action(async (opts) => publish(opts));
11587
+ space.command("publish").option("-y, --yes").option("--bump <type>").option("--private").option("--public").option("--api-key <key>").action(async (opts) => publish(opts));
11544
11588
  space.command("validate").action(() => validate3());
11545
11589
  space.command("check").action(() => check());
11546
11590
  space.command("clean").option("--all").action((opts) => clean(opts));
@@ -1,26 +1,58 @@
1
1
  /**
2
2
  * Space Actions — exposed to the AI agent via space_run_action.
3
3
  *
4
- * Each action: { description, params, run }.
5
- * - `params` describe agent-callable inputs (type, description, required).
6
- * - `run` receives the payload and returns any JSON-serialisable value.
4
+ * Each action: { description, params, run, tier? }.
5
+ * - `params` JSON-schema-ish input shape; each: { type, description?, required? }.
6
+ * - `run` receives the validated payload, returns any JSON-serialisable value.
7
+ * - `tier` (optional) default model bucket for useBrain() calls inside `run`:
8
+ * 'small' fast/cheap — summarisation, classification, short Q&A
9
+ * 'medium' balanced — general help, edits, structured output
10
+ * 'large' reasoning — long-form writing, deep code, planning
11
+ * The host resolves tier → provider+model via the user's tier config
12
+ * (Settings → LLM Providers). Per-call `brain.complete({ tier })`
13
+ * wins over the action default.
14
+ *
15
+ * Permission for useBrain():
16
+ * - Declare `{{.ID}}:brain` in `permissions.catalog` (space.manifest.json).
17
+ * - Add the action to `permissions.actions` mapping to that id, e.g.
18
+ * "permissions": {
19
+ * "actions": { "summarize": "{{.ID}}:brain" },
20
+ * "catalog": [{ "id": "{{.ID}}:brain", "label": "Use AI summaries" }]
21
+ * }
7
22
  *
8
23
  * Pair with @construct-space/graph for typed multi-tenant data:
9
24
  * import { useGraph } from '@construct-space/graph'
10
25
  * import { Item } from './models'
11
26
  * const items = useGraph(Item)
12
- *
13
- * listItems: {
14
- * description: 'List items',
15
- * params: { limit: { type: 'number', required: false } },
16
- * run: async (p: any) => ({ items: await items.find({ limit: p.limit ?? 50 }) }),
17
- * }
18
27
  */
19
28
 
20
- export const actions = {
29
+ import { useBrain } from '@construct-space/sdk'
30
+ import type { SpaceActions } from '@construct-space/sdk'
31
+
32
+ export const actions: SpaceActions = {
21
33
  ping: {
22
34
  description: 'Health check — returns pong with the space id.',
23
35
  params: {},
24
36
  run: () => ({ pong: true, space: '{{.ID}}' }),
25
37
  },
38
+
39
+ // Example: a tier-routed action. Delete or adapt for your space.
40
+ // Requires '{{.ID}}:brain' in permissions.catalog and a row in
41
+ // permissions.actions mapping 'summarize' to that id.
42
+ //
43
+ // summarize: {
44
+ // description: 'Summarise the given text in 1-2 sentences.',
45
+ // tier: 'small',
46
+ // params: {
47
+ // text: { type: 'string', required: true, description: 'Text to summarise.' },
48
+ // },
49
+ // async run({ text }: { text: string }) {
50
+ // const brain = useBrain()
51
+ // if (!brain) return { error: 'brain unavailable' }
52
+ // const { text: summary } = await brain.complete({
53
+ // prompt: `Summarise in 1-2 sentences:\n\n${text}`,
54
+ // })
55
+ // return { summary }
56
+ // },
57
+ // },
26
58
  }
@@ -4,7 +4,12 @@ name: {{.DisplayName}}
4
4
  category: space
5
5
  description: AI agent for the {{.DisplayName}} space
6
6
  maxIterations: 15
7
- tools: []
7
+ # Whitelist of tools the agent can call. Each action in src/actions.ts is
8
+ # exposed as `<space-id>.<actionName>`. Add yours here as you build them;
9
+ # space_list_actions + space_run_action remain available as the fallback
10
+ # discovery bridge, but explicit listing gives the model proper tool schemas.
11
+ tools:
12
+ - {{.ID}}.ping
8
13
  canInvokeAgents: []
9
14
  ---
10
15
 
@@ -12,12 +17,14 @@ You are Construct's {{.DisplayName}} agent. You help users work within the {{.Di
12
17
 
13
18
  ## Context
14
19
 
15
- Use space_list_actions to discover available actions for this space.
16
- Use space_run_action to execute actions.
20
+ Use the listed tools above first. If you need an action that isn't whitelisted,
21
+ call space_list_actions to discover what else this space exposes, then
22
+ space_run_action to invoke it.
23
+
17
24
  Do NOT call get_project_context — you work with space content, not project files.
18
25
 
19
26
  ## Behavior
20
27
 
21
- - Start by listing available actions to understand what you can do
28
+ - Start by understanding what the user wants in this space
22
29
  - Be concise and action-oriented
23
30
  - Focus on tasks relevant to this space
@@ -162,7 +162,7 @@ export const Task = defineModel('task', {
162
162
  labels: field.json(),
163
163
  assignee_id: field.string().index(),
164
164
  }, {
165
- scope: 'org', // partitions schema per organization
165
+ scopes: ['org'], // partitions schema per organization
166
166
  access: {
167
167
  read: access.authenticated(),
168
168
  create: access.authenticated(),
@@ -188,10 +188,12 @@ await tasks.remove(id)
188
188
 
189
189
  **Access helpers**: `authenticated()`, `owner()`, `admin()`, `member()`. `member()` requires org context — use `authenticated()` for org-scoped models that allow any logged-in member.
190
190
 
191
- **Scopes**:
192
- - `app` — single shared schema (rare)
193
- - `org` — partitioned by organization (most product spaces)
194
- - `project` — partitioned by project (dev-tooling spaces)
191
+ **Scopes** (must match `scopes` in space.manifest.json):
192
+ - `'app'` — per-user bucket (personal install)
193
+ - `'org'` — shared across the active organization (org install)
194
+ - both: `scopes: ['app', 'org']` — host picks bucket at install time
195
+
196
+ `'project'` was removed in SDK 1.0. Older docs that show it are stale.
195
197
 
196
198
  **Push models to backend after editing**: `construct graph push`.
197
199
 
@@ -219,7 +221,9 @@ await tasks.remove(id)
219
221
  `src/actions.ts` exports an `actions` object — each entry is exposed to the space's agent as a first-class tool.
220
222
 
221
223
  ```ts
222
- export const actions = {
224
+ import type { SpaceActions } from '@construct-space/sdk'
225
+
226
+ export const actions: SpaceActions = {
223
227
  createTask: {
224
228
  description: 'Create a task',
225
229
  params: {
@@ -231,6 +235,75 @@ export const actions = {
231
235
  }
232
236
  ```
233
237
 
238
+ ### Tier-routed brain calls (LLM from inside an action)
239
+
240
+ Actions can call the model mid-execution via `useBrain()`. The action picks
241
+ the **cost bucket** (small / medium / large), the **user** picks the slot
242
+ in Settings → LLM Providers. The host maps tier → provider + model.
243
+
244
+ ```ts
245
+ import { useBrain } from '@construct-space/sdk'
246
+ import type { SpaceActions } from '@construct-space/sdk'
247
+
248
+ export const actions: SpaceActions = {
249
+ summarizeThread: {
250
+ description: 'Summarise one email thread in 1-2 sentences.',
251
+ tier: 'small', // default for brain calls in `run`
252
+ params: { threadId: { type: 'string', required: true } },
253
+ async run({ threadId }) {
254
+ const brain = useBrain()
255
+ if (!brain) return { error: 'brain unavailable' }
256
+ const thread = await loadThread(threadId as string)
257
+ const { text } = await brain.complete({ prompt: `Summarise:\n${thread.body}` })
258
+ return { summary: text }
259
+ },
260
+ },
261
+
262
+ composeReply: {
263
+ description: 'Draft a polished reply to the given message.',
264
+ tier: 'large', // long-form writing → opus-class
265
+ params: {
266
+ original: { type: 'string', required: true },
267
+ style: { type: 'string', required: false, description: 'e.g. "warm", "formal"' },
268
+ },
269
+ async run({ original, style }) {
270
+ const brain = useBrain()
271
+ if (!brain) return { error: 'brain unavailable' }
272
+ const { text } = await brain.chat({
273
+ system: 'You write replies that feel like a thoughtful human wrote them.',
274
+ messages: [{ role: 'user', content: `${style ? `Tone: ${style}.\n\n` : ''}Reply to:\n${original}` }],
275
+ })
276
+ return { draft: text }
277
+ },
278
+ },
279
+ }
280
+ ```
281
+
282
+ Per-call overrides win: `brain.complete({ prompt, tier: 'large' })` upgrades
283
+ even a small-tier action when the user asked for "detailed".
284
+
285
+ **Permission required.** A space whose actions call brain must declare
286
+ `{{.ID}}:brain` in `permissions.catalog` and map each brain-using action
287
+ to it under `permissions.actions`. The user grants this at install time.
288
+
289
+ ```jsonc
290
+ // space.manifest.json
291
+ {
292
+ "permissions": {
293
+ "actions": {
294
+ "summarizeThread": "{{.ID}}:brain",
295
+ "composeReply": "{{.ID}}:brain"
296
+ },
297
+ "catalog": [
298
+ { "id": "{{.ID}}:brain", "label": "Use AI for summaries & drafts" }
299
+ ]
300
+ }
301
+ }
302
+ ```
303
+
304
+ Without the grant, `useBrain()` throws `BrainPermissionDenied` synchronously
305
+ on the first method call so the action can fall back gracefully.
306
+
234
307
  ### Wiring (REQUIRED — easy to miss)
235
308
 
236
309
  Each action becomes a tool named **`{spaceID}.{actionName}`** (dot, not underscore). The operator only pre-registers actions that are **explicitly whitelisted** in the agent config. An empty `tools: []` means the agent sees zero action tools and will report "unknown tool".
@@ -1,5 +1,6 @@
1
1
  // Space entry — exports pages, widgets, and actions for the host loader.
2
2
  // `construct dev` regenerates this from space.manifest.json on changes.
3
+ import './style.css'
3
4
  import IndexPage from './pages/index.vue'
4
5
  import { actions } from './actions'
5
6
 
@@ -1,5 +1,6 @@
1
1
  // Space entry — exports pages, widgets, and actions for the host loader.
2
2
  // `construct dev` regenerates this from space.manifest.json on changes.
3
+ import './style.css'
3
4
  import IndexPage from './pages/index.vue'
4
5
  import SettingsPage from './pages/settings.vue'
5
6
  import { actions } from './actions'
@@ -20,10 +20,12 @@
20
20
  "@construct-space/cli": "latest",
21
21
  "@construct-space/sdk": "latest",
22
22
  "@eslint/js": "^10.0.1",
23
+ "@tailwindcss/vite": "^4.1.17",
23
24
  "@vitejs/plugin-vue": "^6.0.6",
24
25
  "eslint": "^10.2.1",
25
26
  "eslint-plugin-vue": "^10.0.0",
26
27
  "lucide-vue-next": "^1.0.0",
28
+ "tailwindcss": "^4.1.17",
27
29
  "typescript": "^5.9.3",
28
30
  "typescript-eslint": "^8.0.0",
29
31
  "vite": "^8.0.12",
@@ -0,0 +1,5 @@
1
+ @import "tailwindcss";
2
+
3
+ @source "./**/*.{vue,ts,js}";
4
+ @source "../widgets/**/*.{vue,ts,js}";
5
+ @source "../node_modules/@construct-space/ui/dist/**/*.{js,vue}";
@@ -1,4 +1,5 @@
1
1
  import { defineConfig } from 'vite'
2
+ import tailwindcss from '@tailwindcss/vite'
2
3
  import vue from '@vitejs/plugin-vue'
3
4
  import { resolve } from 'path'
4
5
 
@@ -19,8 +20,9 @@ const hostExternals = [
19
20
  'dexie',
20
21
  'zod',
21
22
  '@construct-space/ui',
22
- '@construct/sdk',
23
23
  '@construct-space/sdk',
24
+ '@construct-space/graph',
25
+ '@construct/sdk', // legacy alias — keep during cutover
24
26
  ]
25
27
 
26
28
  function makeGlobals(externals: string[]): Record<string, string> {
@@ -37,7 +39,7 @@ function makeGlobals(externals: string[]): Record<string, string> {
37
39
  }
38
40
 
39
41
  export default defineConfig({
40
- plugins: [vue()],
42
+ plugins: [tailwindcss(), vue()],
41
43
  build: {
42
44
  lib: {
43
45
  entry: resolve(__dirname, 'src/entry.ts'),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@construct-space/cli",
3
- "version": "1.7.6",
3
+ "version": "1.8.0",
4
4
  "description": "Construct CLI — scaffold, build, develop, and publish spaces",
5
5
  "type": "module",
6
6
  "bin": {
@@ -1,26 +1,58 @@
1
1
  /**
2
2
  * Space Actions — exposed to the AI agent via space_run_action.
3
3
  *
4
- * Each action: { description, params, run }.
5
- * - `params` describe agent-callable inputs (type, description, required).
6
- * - `run` receives the payload and returns any JSON-serialisable value.
4
+ * Each action: { description, params, run, tier? }.
5
+ * - `params` JSON-schema-ish input shape; each: { type, description?, required? }.
6
+ * - `run` receives the validated payload, returns any JSON-serialisable value.
7
+ * - `tier` (optional) default model bucket for useBrain() calls inside `run`:
8
+ * 'small' fast/cheap — summarisation, classification, short Q&A
9
+ * 'medium' balanced — general help, edits, structured output
10
+ * 'large' reasoning — long-form writing, deep code, planning
11
+ * The host resolves tier → provider+model via the user's tier config
12
+ * (Settings → LLM Providers). Per-call `brain.complete({ tier })`
13
+ * wins over the action default.
14
+ *
15
+ * Permission for useBrain():
16
+ * - Declare `{{.ID}}:brain` in `permissions.catalog` (space.manifest.json).
17
+ * - Add the action to `permissions.actions` mapping to that id, e.g.
18
+ * "permissions": {
19
+ * "actions": { "summarize": "{{.ID}}:brain" },
20
+ * "catalog": [{ "id": "{{.ID}}:brain", "label": "Use AI summaries" }]
21
+ * }
7
22
  *
8
23
  * Pair with @construct-space/graph for typed multi-tenant data:
9
24
  * import { useGraph } from '@construct-space/graph'
10
25
  * import { Item } from './models'
11
26
  * const items = useGraph(Item)
12
- *
13
- * listItems: {
14
- * description: 'List items',
15
- * params: { limit: { type: 'number', required: false } },
16
- * run: async (p: any) => ({ items: await items.find({ limit: p.limit ?? 50 }) }),
17
- * }
18
27
  */
19
28
 
20
- export const actions = {
29
+ import { useBrain } from '@construct-space/sdk'
30
+ import type { SpaceActions } from '@construct-space/sdk'
31
+
32
+ export const actions: SpaceActions = {
21
33
  ping: {
22
34
  description: 'Health check — returns pong with the space id.',
23
35
  params: {},
24
36
  run: () => ({ pong: true, space: '{{.ID}}' }),
25
37
  },
38
+
39
+ // Example: a tier-routed action. Delete or adapt for your space.
40
+ // Requires '{{.ID}}:brain' in permissions.catalog and a row in
41
+ // permissions.actions mapping 'summarize' to that id.
42
+ //
43
+ // summarize: {
44
+ // description: 'Summarise the given text in 1-2 sentences.',
45
+ // tier: 'small',
46
+ // params: {
47
+ // text: { type: 'string', required: true, description: 'Text to summarise.' },
48
+ // },
49
+ // async run({ text }: { text: string }) {
50
+ // const brain = useBrain()
51
+ // if (!brain) return { error: 'brain unavailable' }
52
+ // const { text: summary } = await brain.complete({
53
+ // prompt: `Summarise in 1-2 sentences:\n\n${text}`,
54
+ // })
55
+ // return { summary }
56
+ // },
57
+ // },
26
58
  }
@@ -4,7 +4,12 @@ name: {{.DisplayName}}
4
4
  category: space
5
5
  description: AI agent for the {{.DisplayName}} space
6
6
  maxIterations: 15
7
- tools: []
7
+ # Whitelist of tools the agent can call. Each action in src/actions.ts is
8
+ # exposed as `<space-id>.<actionName>`. Add yours here as you build them;
9
+ # space_list_actions + space_run_action remain available as the fallback
10
+ # discovery bridge, but explicit listing gives the model proper tool schemas.
11
+ tools:
12
+ - {{.ID}}.ping
8
13
  canInvokeAgents: []
9
14
  ---
10
15
 
@@ -12,12 +17,14 @@ You are Construct's {{.DisplayName}} agent. You help users work within the {{.Di
12
17
 
13
18
  ## Context
14
19
 
15
- Use space_list_actions to discover available actions for this space.
16
- Use space_run_action to execute actions.
20
+ Use the listed tools above first. If you need an action that isn't whitelisted,
21
+ call space_list_actions to discover what else this space exposes, then
22
+ space_run_action to invoke it.
23
+
17
24
  Do NOT call get_project_context — you work with space content, not project files.
18
25
 
19
26
  ## Behavior
20
27
 
21
- - Start by listing available actions to understand what you can do
28
+ - Start by understanding what the user wants in this space
22
29
  - Be concise and action-oriented
23
30
  - Focus on tasks relevant to this space
@@ -162,7 +162,7 @@ export const Task = defineModel('task', {
162
162
  labels: field.json(),
163
163
  assignee_id: field.string().index(),
164
164
  }, {
165
- scope: 'org', // partitions schema per organization
165
+ scopes: ['org'], // partitions schema per organization
166
166
  access: {
167
167
  read: access.authenticated(),
168
168
  create: access.authenticated(),
@@ -188,10 +188,12 @@ await tasks.remove(id)
188
188
 
189
189
  **Access helpers**: `authenticated()`, `owner()`, `admin()`, `member()`. `member()` requires org context — use `authenticated()` for org-scoped models that allow any logged-in member.
190
190
 
191
- **Scopes**:
192
- - `app` — single shared schema (rare)
193
- - `org` — partitioned by organization (most product spaces)
194
- - `project` — partitioned by project (dev-tooling spaces)
191
+ **Scopes** (must match `scopes` in space.manifest.json):
192
+ - `'app'` — per-user bucket (personal install)
193
+ - `'org'` — shared across the active organization (org install)
194
+ - both: `scopes: ['app', 'org']` — host picks bucket at install time
195
+
196
+ `'project'` was removed in SDK 1.0. Older docs that show it are stale.
195
197
 
196
198
  **Push models to backend after editing**: `construct graph push`.
197
199
 
@@ -219,7 +221,9 @@ await tasks.remove(id)
219
221
  `src/actions.ts` exports an `actions` object — each entry is exposed to the space's agent as a first-class tool.
220
222
 
221
223
  ```ts
222
- export const actions = {
224
+ import type { SpaceActions } from '@construct-space/sdk'
225
+
226
+ export const actions: SpaceActions = {
223
227
  createTask: {
224
228
  description: 'Create a task',
225
229
  params: {
@@ -231,6 +235,75 @@ export const actions = {
231
235
  }
232
236
  ```
233
237
 
238
+ ### Tier-routed brain calls (LLM from inside an action)
239
+
240
+ Actions can call the model mid-execution via `useBrain()`. The action picks
241
+ the **cost bucket** (small / medium / large), the **user** picks the slot
242
+ in Settings → LLM Providers. The host maps tier → provider + model.
243
+
244
+ ```ts
245
+ import { useBrain } from '@construct-space/sdk'
246
+ import type { SpaceActions } from '@construct-space/sdk'
247
+
248
+ export const actions: SpaceActions = {
249
+ summarizeThread: {
250
+ description: 'Summarise one email thread in 1-2 sentences.',
251
+ tier: 'small', // default for brain calls in `run`
252
+ params: { threadId: { type: 'string', required: true } },
253
+ async run({ threadId }) {
254
+ const brain = useBrain()
255
+ if (!brain) return { error: 'brain unavailable' }
256
+ const thread = await loadThread(threadId as string)
257
+ const { text } = await brain.complete({ prompt: `Summarise:\n${thread.body}` })
258
+ return { summary: text }
259
+ },
260
+ },
261
+
262
+ composeReply: {
263
+ description: 'Draft a polished reply to the given message.',
264
+ tier: 'large', // long-form writing → opus-class
265
+ params: {
266
+ original: { type: 'string', required: true },
267
+ style: { type: 'string', required: false, description: 'e.g. "warm", "formal"' },
268
+ },
269
+ async run({ original, style }) {
270
+ const brain = useBrain()
271
+ if (!brain) return { error: 'brain unavailable' }
272
+ const { text } = await brain.chat({
273
+ system: 'You write replies that feel like a thoughtful human wrote them.',
274
+ messages: [{ role: 'user', content: `${style ? `Tone: ${style}.\n\n` : ''}Reply to:\n${original}` }],
275
+ })
276
+ return { draft: text }
277
+ },
278
+ },
279
+ }
280
+ ```
281
+
282
+ Per-call overrides win: `brain.complete({ prompt, tier: 'large' })` upgrades
283
+ even a small-tier action when the user asked for "detailed".
284
+
285
+ **Permission required.** A space whose actions call brain must declare
286
+ `{{.ID}}:brain` in `permissions.catalog` and map each brain-using action
287
+ to it under `permissions.actions`. The user grants this at install time.
288
+
289
+ ```jsonc
290
+ // space.manifest.json
291
+ {
292
+ "permissions": {
293
+ "actions": {
294
+ "summarizeThread": "{{.ID}}:brain",
295
+ "composeReply": "{{.ID}}:brain"
296
+ },
297
+ "catalog": [
298
+ { "id": "{{.ID}}:brain", "label": "Use AI for summaries & drafts" }
299
+ ]
300
+ }
301
+ }
302
+ ```
303
+
304
+ Without the grant, `useBrain()` throws `BrainPermissionDenied` synchronously
305
+ on the first method call so the action can fall back gracefully.
306
+
234
307
  ### Wiring (REQUIRED — easy to miss)
235
308
 
236
309
  Each action becomes a tool named **`{spaceID}.{actionName}`** (dot, not underscore). The operator only pre-registers actions that are **explicitly whitelisted** in the agent config. An empty `tools: []` means the agent sees zero action tools and will report "unknown tool".
@@ -1,5 +1,6 @@
1
1
  // Space entry — exports pages, widgets, and actions for the host loader.
2
2
  // `construct dev` regenerates this from space.manifest.json on changes.
3
+ import './style.css'
3
4
  import IndexPage from './pages/index.vue'
4
5
  import { actions } from './actions'
5
6
 
@@ -1,5 +1,6 @@
1
1
  // Space entry — exports pages, widgets, and actions for the host loader.
2
2
  // `construct dev` regenerates this from space.manifest.json on changes.
3
+ import './style.css'
3
4
  import IndexPage from './pages/index.vue'
4
5
  import SettingsPage from './pages/settings.vue'
5
6
  import { actions } from './actions'
@@ -20,10 +20,12 @@
20
20
  "@construct-space/cli": "latest",
21
21
  "@construct-space/sdk": "latest",
22
22
  "@eslint/js": "^10.0.1",
23
+ "@tailwindcss/vite": "^4.1.17",
23
24
  "@vitejs/plugin-vue": "^6.0.6",
24
25
  "eslint": "^10.2.1",
25
26
  "eslint-plugin-vue": "^10.0.0",
26
27
  "lucide-vue-next": "^1.0.0",
28
+ "tailwindcss": "^4.1.17",
27
29
  "typescript": "^5.9.3",
28
30
  "typescript-eslint": "^8.0.0",
29
31
  "vite": "^8.0.12",
@@ -0,0 +1,5 @@
1
+ @import "tailwindcss";
2
+
3
+ @source "./**/*.{vue,ts,js}";
4
+ @source "../widgets/**/*.{vue,ts,js}";
5
+ @source "../node_modules/@construct-space/ui/dist/**/*.{js,vue}";
@@ -1,4 +1,5 @@
1
1
  import { defineConfig } from 'vite'
2
+ import tailwindcss from '@tailwindcss/vite'
2
3
  import vue from '@vitejs/plugin-vue'
3
4
  import { resolve } from 'path'
4
5
 
@@ -19,8 +20,9 @@ const hostExternals = [
19
20
  'dexie',
20
21
  'zod',
21
22
  '@construct-space/ui',
22
- '@construct/sdk',
23
23
  '@construct-space/sdk',
24
+ '@construct-space/graph',
25
+ '@construct/sdk', // legacy alias — keep during cutover
24
26
  ]
25
27
 
26
28
  function makeGlobals(externals: string[]): Record<string, string> {
@@ -37,7 +39,7 @@ function makeGlobals(externals: string[]): Record<string, string> {
37
39
  }
38
40
 
39
41
  export default defineConfig({
40
- plugins: [vue()],
42
+ plugins: [tailwindcss(), vue()],
41
43
  build: {
42
44
  lib: {
43
45
  entry: resolve(__dirname, 'src/entry.ts'),