@nullplatform/mcp 0.1.13 → 0.1.14

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 (48) hide show
  1. package/dist/np/context.js +6 -2
  2. package/dist/surfaces/developer.js +9 -2
  3. package/dist/tools/approvals.js +1 -1
  4. package/dist/tools/builds.js +1 -1
  5. package/dist/tools/create-app.js +40 -28
  6. package/dist/tools/create-link.js +1 -1
  7. package/dist/tools/create-release.js +1 -1
  8. package/dist/tools/create-scope.js +1 -1
  9. package/dist/tools/create-service.js +12 -12
  10. package/dist/tools/delete-link.js +1 -1
  11. package/dist/tools/delete-param.js +1 -1
  12. package/dist/tools/delete-service.js +1 -1
  13. package/dist/tools/deploy.js +1 -1
  14. package/dist/tools/deployments.js +1 -1
  15. package/dist/tools/entity-get.js +1 -1
  16. package/dist/tools/entity-list.js +1 -1
  17. package/dist/tools/find-apps.js +5 -2
  18. package/dist/tools/logs.js +1 -1
  19. package/dist/tools/metrics.js +1 -1
  20. package/dist/tools/overview.js +1 -1
  21. package/dist/tools/params.js +1 -1
  22. package/dist/tools/playbook.js +1 -1
  23. package/dist/tools/releases.js +1 -1
  24. package/dist/tools/services.js +1 -1
  25. package/dist/tools/set-params.js +1 -1
  26. package/dist/tools/shared.js +43 -0
  27. package/dist/tools/status.js +1 -1
  28. package/dist/tools/traffic.js +1 -1
  29. package/dist/tools/update-link.js +1 -1
  30. package/dist/tools/update-service.js +1 -1
  31. package/package.json +4 -1
  32. package/widgets-dist/approvals.html +9 -1
  33. package/widgets-dist/builds.html +9 -1
  34. package/widgets-dist/create-app.html +18 -10
  35. package/widgets-dist/deployments.html +9 -1
  36. package/widgets-dist/find-apps.html +9 -1
  37. package/widgets-dist/logs.html +9 -1
  38. package/widgets-dist/manifest.json +16 -16
  39. package/widgets-dist/metrics.html +9 -1
  40. package/widgets-dist/np-panel.html +9 -1
  41. package/widgets-dist/overview.html +9 -1
  42. package/widgets-dist/params.html +22 -14
  43. package/widgets-dist/releases.html +9 -1
  44. package/widgets-dist/service-action.html +9 -1
  45. package/widgets-dist/service-create.html +9 -1
  46. package/widgets-dist/service-delete.html +9 -1
  47. package/widgets-dist/service-link.html +9 -1
  48. package/widgets-dist/services.html +15 -7
@@ -124,9 +124,13 @@ export class NpContext {
124
124
  /** Search apps by partial name (and/or namespace name) across the whole org — parallel + cached. */
125
125
  async findApps(args = {}) {
126
126
  const skeleton = await this.getSkeleton();
127
- const namespaceFilter = args.namespace?.toLowerCase();
127
+ // Match namespace names leniently: strip case AND non-alphanumerics on both sides so "pablov",
128
+ // "pablo v" and "Pablo-V" all resolve the namespace displayed as "Pablo V" (a strict substring
129
+ // missed it on the space, which is why a namespace query fell back to dumping the whole org).
130
+ const normalize = (text) => text.toLowerCase().replace(/[^a-z0-9]+/g, "");
131
+ const namespaceFilter = args.namespace ? normalize(args.namespace) : undefined;
128
132
  const namespaces = namespaceFilter
129
- ? skeleton.namespaces.filter((namespace) => namespace.name.toLowerCase().includes(namespaceFilter))
133
+ ? skeleton.namespaces.filter((namespace) => normalize(namespace.name).includes(namespaceFilter))
130
134
  : skeleton.namespaces;
131
135
  const nameFilter = args.query?.toLowerCase();
132
136
  // `/application` is namespace-scoped with no name filter, so page each namespace to completion
@@ -14,9 +14,16 @@ Every tool accepts \`language\`: ALWAYS set it to the language the user is conve
14
14
 
15
15
  Most tools render an interactive panel in clients that support it — apps, status, builds, releases, deployments, logs, metrics, parameters and approvals all appear as live UI. **When a tool's panel renders, that panel IS the answer: do not reproduce its data in your text reply.** The user already sees every row, status and value. NEVER print a markdown table of the same rows, re-list the items, or restate per-row status — duplicating the panel in text is the single most common mistake. Reply with AT MOST one short sentence — the one key takeaway or the next step — or nothing at all. A one-line insight the panel doesn't itself show is fine ("builds 3 and 5 were never released"); re-rendering the list as a table is not.
16
16
 
17
- When the user wants to create, scaffold, set up or import an application, drive it through the \`application_create\` FORM — its panel collects the namespace, template and repository. Two rules keep this clean. (1) Settle any genuinely STRUCTURAL question about WHAT to build — e.g. "is this one app or two, a frontend and an API?" in one short turn BEFORE opening any form, since each app gets its own form. (2) Never gather FORM-FIELD details (namespace, template, new-vs-import repository, monorepo path) in conversation: INFER the best-fitting namespace and account from what the app is FOR — a demo → a demos/examples namespace, a backend service → a services namespace — and pass them as \`namespace\`/\`account\` (plus \`name\` if given) so the form opens pre-selected on your inference; the user changes it in the form if they want. Once a form is on screen, NEVER ask a clarifying question or use any question-asking tool about a field it covers — above all NEVER ask "which namespace?". And NEVER open the form, ask a question, then re-open the form: that form → question → form flow is the single worst thing you can do here. Open it once, pre-filled, and let it drive; react only to what it reports back.
17
+ NEVER render a panel-rendering read MORE THAN ONCE to answer one questionnot in a wait loop, and not fanned out across several apps/scopes. Two shapes of this mistake:
18
+ - POLLING a changing status (a build finishing, a deployment rolling out, an app going active): do the waiting with the DATA-ONLY reads (\`entity_get\`/\`entity_list\`, which render NO panel) — e.g. loop \`entity_get entity:"build" id:<id>\` until its status is final. Re-calling \`application_get\` once per poll repaints the whole panel every iteration and stacks identical panels down the chat (the "looping on app_get" mistake).
19
+ - AGGREGATING or COMPARING across many entities ("the latest release across all my apps", "which scope is on the newest build", "do any apps have failing builds"): do NOT call \`application_release_list\`/\`application_build_list\`/… once per app and dump a panel for each — that floods the chat and still doesn't answer the question. Gather headlessly with \`entity_list\` per app (\`entity_list entity:"release" parent_type:"application" parent_id:<id>\`), compute the answer yourself (sort by created_at, pick the max), and ANSWER IT — in one sentence ("the newest is auth-api 0.0.2, cut today"), or by rendering a SINGLE panel for just the one app/entity worth showing. The user asked for an answer, not N lists to eyeball.
20
+ Render headlessly while you gather; show a panel at most once, for the one state worth seeing.
18
21
 
19
- Creating a NEW app is the PLATFORM's job, never a local scaffold. nullplatform creates the GitHub repository from a template and provisions it so for a new app you must NEVER run a generic brainstorm/design/implementation process, never \`git init\`, never create an empty repo, and never write code locally first. The flow is: settle what to build \`application_create\` with a new repo and ONE OF THE PLATFORM'S TEMPLATES → the platform creates the repo on GitHub → THEN clone it, add code, push so CI builds. The available STACKS are exactly those templates for the chosen namespace never invent or offer a stack of your own ("Next.js", "Express", …); the form lists the real templates, choose from those. And do NOT fire the rendering reads (\`application_list\`/\`application_get\`/\`application_build_list\`…) just to orient yourself before any of this each renders a panel the user didn't ask for. To gather data for your OWN reasoning, navigate the entity tree headlessly with \`entity_list\` and \`entity_get\` read-only, DATA-only, NO panel. \`entity_list\` lists a collection scoped to its parent (the tree is organization account namespace application {scope, build, release}; scope → deployment): e.g. \`entity_list entity:"application" parent_type:"namespace" parent_id:11\`, then \`entity_list entity:"build" parent_type:"application" parent_id:123\`. \`entity_get entity:"application" id:123\` reads one. Parameters, services, links and approvals list the same way under an application or scope (\`entity_list entity:"parameter" parent_type:"application" parent_id:123\`). That is how you learn where apps live, how they're named, and what's deployable without putting a list on the user's screen. Reserve the rendered reads (\`application_list\`/\`application_get\`/\`application_build_list\`/…) for when the user actually wants to SEE that data.
22
+ EVERY form this server opens follows one contract — app creation, service provisioning, scope creation, parameter setting, service linking, release creation, all of them. (1) GATHER what you need to pre-fill BEFORE opening the form: settle structural choices and ask for the missing context first, and ask any enumerable choice INTERACTIVELY (the client's question/choice tool with concrete options) so the user clicks rather than reads a typed list free-text fields the form already has (a name, a URL) you let the form collect. (2) Open the form ONCE, pre-filled from your inference: carry each best-guess in as the field's hint (a namespace, a stack/template, a service \`type\`…) so the tool pre-selects it never describe the choice in prose and leave the picker blank, and never open a blank or half-filled form and keep asking. (3) Once the form is on screen, NEVER ask a clarifying question or fire a question tool about a field the form already covers those controls ARE the form; you only react to what it reports back. The rest of this section is that contract applied to app creation; the same three rules hold for every other form.
23
+
24
+ When the user wants to create, scaffold, set up or import an application, drive it through the \`application_create\` FORM — its panel collects the namespace, template and repository. Two rules keep this clean. (1) Settle any genuinely STRUCTURAL question about WHAT to build — e.g. "is this one app or two, a frontend and an API?" — in one short turn BEFORE opening any form, since each app gets its own form. (2) Never gather FORM-FIELD details (namespace, template, new-vs-import repository, monorepo path) in conversation: INFER the best-fitting namespace and account from what the app is FOR — a demo → a demos/examples namespace, a backend service → a services namespace — and pass them as \`namespace\`/\`account\` (plus \`name\` if given) so the form opens pre-selected on your inference; the user changes it in the form if they want. When the request gives you NOTHING to infer from — a bare "create an app" with no hint of what it's for or what to call it — FINISH gathering context BEFORE opening any form: ask what the app is FOR (this fixes the namespace) and what to NAME it, and WAIT for both answers. Ask the "what is it for" part as an INTERACTIVE question — use the client's question/choice tool (e.g. AskUserQuestion) with concrete options (a backend API, a frontend, a demo, a worker/queue consumer, a CLI…) so the user picks in one click instead of reading a typed list; the NAME is free text, so ask it alongside or let the form's NAME field collect it, but never force a name into a multiple-choice question. Only THEN open the form, once, fully pre-filled — namespace inferred from the purpose, name carried in via \`name\`. Do NOT open the form "in the meantime", "while you're at it", or "to save time" with a question still pending: the form comes AFTER the context is in hand, never alongside an open question, and never half-filled while you keep asking. Opening a blank or partial form and continuing to ask is the form → question anti-pattern and the single worst thing you can do here. So: enough context already in the request → open the form pre-filled, no questions; missing context → gather ALL of it first (purpose AND name), THEN the one fully pre-filled form. Once a form is on screen, NEVER ask a clarifying question or use any question-asking tool about a field it covers — above all NEVER ask "which namespace?". The form ALREADY contains a "New repository | Import existing" toggle, a namespace picker, a template picker and a monorepo path field — so NEVER, after opening it, present a question like "How should the repository be set up?" with "New from template / Import existing" options, and never re-ask namespace or template: those controls ARE the form, and duplicating them as a follow-up question is the exact mistake to avoid (it is what produces the form-then-question screen). Any question you genuinely need comes BEFORE the form and the form opens AFTER it, pre-filled; once the form is up, the only thing you do is react to what it reports back.
25
+
26
+ Creating a NEW app is the PLATFORM's job, never a local scaffold. nullplatform creates the GitHub repository from a template and provisions it — so for a new app you must NEVER run a generic brainstorm/design/implementation process, never \`git init\`, never create an empty repo, and never write code locally first. The flow is: settle what to build → \`application_create\` with a new repo and ONE OF THE PLATFORM'S TEMPLATES → the platform creates the repo on GitHub → THEN clone it, add code, push so CI builds. The available STACKS are exactly those templates for the chosen namespace — never invent or offer a stack of your own ("Next.js", "Express", …); the form lists the real templates, choose from those. Carry your stack inference into the form the SAME way you carry the namespace: pass \`template\` with the stack you'd pick from what the app is FOR (a Go API → "go", a React frontend → "react"/"node", a Python service → "python") and the form opens with that template pre-selected — don't just name the best fit in prose and leave the field blank. This pre-fill-from-inference rule is general: every create/fill form (template, service \`type\`, scope, …) pre-selects from the hint you pass, so always pass the hint rather than describing the choice and leaving the picker empty. And do NOT fire the rendering reads (\`application_list\`/\`application_get\`/\`application_build_list\`…) just to orient yourself before any of this — each renders a panel the user didn't ask for. To gather data for your OWN reasoning, navigate the entity tree headlessly with \`entity_list\` and \`entity_get\` — read-only, DATA-only, NO panel. \`entity_list\` lists a collection scoped to its parent (the tree is organization → account → namespace → application → {scope, build, release}; scope → deployment): e.g. \`entity_list entity:"application" parent_type:"namespace" parent_id:11\`, then \`entity_list entity:"build" parent_type:"application" parent_id:123\`. \`entity_get entity:"application" id:123\` reads one. Parameters, services, links and approvals list the same way under an application or scope (\`entity_list entity:"parameter" parent_type:"application" parent_id:123\`). That is how you learn where apps live, how they're named, and what's deployable without putting a list on the user's screen. Reserve the rendered reads (\`application_list\`/\`application_get\`/\`application_build_list\`/…) for when the user actually wants to SEE that data.
20
27
 
21
28
  Typical flows:
22
29
  - Ship: \`application_deployment_create\` (picks the latest build, cuts the release for you) → \`application_deployment_update percent:25/50/100\` → \`application_deployment_update action:"finalize"\`.
@@ -14,7 +14,7 @@ import { appArg, requireApp } from "./shared.js";
14
14
  export const approvalsTool = defineTool({
15
15
  name: TOOL.applicationApprovalList,
16
16
  title: "Approvals",
17
- description: 'List the approvals gating an application\'s actions (e.g. a deployment waiting on a policy), and act on one. action:"approve" lets the gated action proceed; action:"cancel" cancels the request. Both use your own permissions the platform denies what you\'re not allowed to do. Use this when a deploy is stuck in creating_approval.',
17
+ description: 'List the approvals gating an application\'s actions (e.g. a deployment waiting on a policy), rendered as a PANEL, and act on one. action:"approve" lets the gated action proceed; action:"cancel" cancels the request both run with YOUR own permissions, so the platform denies what you\'re not allowed to do. Use it when "a deploy is stuck", "creating_approval", "approve the deployment", "what\'s waiting on approval". Does NOT deploy or change traffic — it only resolves the gate; the gated action proceeds on its own once approved.',
18
18
  annotations: { destructiveHint: false, openWorldHint: true },
19
19
  widget: "approvals",
20
20
  errorKey: "approvals.errorLabel",
@@ -13,7 +13,7 @@ import { appArg, offsetArg, pageOf, requireApp } from "./shared.js";
13
13
  export const buildsTool = defineTool({
14
14
  name: TOOL.applicationBuildList,
15
15
  title: "Builds",
16
- description: "List an application's recent CI builds — status, branch, commit, age, and whether each is already released. Use it to answer 'did my push build yet?' and to pick a build_id to deploy. Pass build:<id> to see that build's assets, or commit:<sha> to find the build for a specific commit (tracing a change to its build and release).",
16
+ description: "List ONE application's recent CI builds as a PANEL for the user — status, branch, commit, age, and whether each is already released. Use it to answer 'did my push build yet?' and to pick a build_id to deploy. Pass build:<id> to see that build's assets, or commit:<sha> to find the build for a specific commit. Do NOT call this once per app to compare across apps, and do NOT loop it to wait for a build to finish (it renders a panel each time): for an AGGREGATE across apps use entity_list (entity:\"build\"), and to POLL a build to completion use entity_get (entity:\"build\" id:<id>) — both headless.",
17
17
  annotations: { readOnlyHint: true, openWorldHint: true },
18
18
  widget: "builds",
19
19
  errorKey: "builds.errorLabel",
@@ -4,24 +4,24 @@ import { dashboardLink, glyph, linkLine, next } from "../md.js";
4
4
  import { createApplication, getApplication, listTemplates } from "../np/journey.js";
5
5
  import { defineTool, fail, reply } from "../tool.js";
6
6
  import { TOOL } from "../tool-names.js";
7
- import { delays, httpsRepoUrl, sleep } from "./shared.js";
8
- function pickNamespace(namespaces, hints) {
7
+ import { delays, httpsRepoUrl, resolveChoice, sleep } from "./shared.js";
8
+ export function pickNamespace(namespaces, hints) {
9
+ // Namespace is the generic resolveChoice with one extra twist — account narrowing. An explicit id is
10
+ // unambiguous, so honour it against the FULL list FIRST (the account filter only disambiguates a
11
+ // repeated NAME; it must never veto a real id). Then resolve the name within the named account, and
12
+ // fall back to all namespaces so a correct name still pins even when the account label doesn't line up.
13
+ const byId = resolveChoice(namespaces, { id: hints.namespace_id });
14
+ if (byId)
15
+ return byId;
9
16
  const accountQuery = hints.account?.toLowerCase();
10
- const narrowed = accountQuery
17
+ const inAccount = accountQuery
11
18
  ? namespaces.filter((candidate) => (candidate.account_name ?? "").toLowerCase().includes(accountQuery))
12
19
  : namespaces;
13
- const candidates = narrowed.length ? narrowed : namespaces;
14
- if (hints.namespace_id) {
15
- const byId = candidates.find((candidate) => candidate.id === hints.namespace_id);
16
- if (byId)
17
- return byId;
18
- }
19
- if (hints.namespace) {
20
- const query = hints.namespace.toLowerCase();
21
- const matches = candidates.filter((candidate) => candidate.name.toLowerCase().includes(query));
22
- if (matches.length === 1)
23
- return matches[0];
24
- }
20
+ const named = resolveChoice(inAccount.length ? inAccount : namespaces, { text: hints.namespace }) ??
21
+ resolveChoice(namespaces, { text: hints.namespace });
22
+ if (named)
23
+ return named;
24
+ const candidates = inAccount.length ? inAccount : namespaces;
25
25
  if (candidates.length === 1)
26
26
  return candidates[0];
27
27
  return undefined;
@@ -41,11 +41,11 @@ async function buildFormReply(context, prefill) {
41
41
  // of the global set. Fetch only once a namespace is pinned (the widget refetches per namespace on
42
42
  // change); the org-level NRN returns a misleading near-empty subset.
43
43
  const templates = picked ? await listTemplates(context.np, picked.nrn).catch(() => []) : [];
44
- // Same contract for the template: honour a passed id, pin the only one, else leave it unselected —
45
- // never silently scaffold the first stack in the list (a wrong stack is worse than a deliberate pick).
46
- const templateId = (prefill.template_id && templates.some((template) => template.id === prefill.template_id)
47
- ? prefill.template_id
48
- : null) ?? (templates.length === 1 ? (templates[0]?.id ?? null) : null);
44
+ // Same generic rule as the namespace: resolve the model's stack hint (`template`) or an explicit
45
+ // template_id against THIS namespace's templates, matching on name + tags, and pin the only one.
46
+ // Leave it null when it's a real unhinted choice; never silently scaffold the first stack in the list
47
+ // (a wrong stack is worse than a deliberate pick).
48
+ const templateId = resolveChoice(templates, { id: prefill.template_id, text: prefill.template }, { pinOnly: true, haystack: (template) => `${template.name} ${(template.tags ?? []).join(" ")}` })?.id ?? null;
49
49
  const namespaces = skeleton.namespaces.map((namespace) => ({
50
50
  id: namespace.id,
51
51
  name: namespace.name,
@@ -75,7 +75,7 @@ async function buildFormReply(context, prefill) {
75
75
  export const createAppTool = defineTool({
76
76
  name: TOOL.applicationCreate,
77
77
  title: "Create application",
78
- description: "Create a nullplatform application. INFER the best-fitting namespace and account from what the app is FOR (a demo → a demos/examples namespace, a backend service → a services namespace) and pass them as `namespace`/`account` so the form opens pre-selected on your inference — the user can still change it. Calling with NO repository_url opens an interactive form and creates nothing — the user confirms the namespace, then either a NEW repository (scaffolded from a template, repo URL auto-generated) or IMPORTS an existing repository (optionally a monorepo folder), and the form calls back with a repository_url to actually create. Only an explicit repository_url performs the create, so opening the form never writes anything. Provisioning is async — the create waits briefly and reports progress.",
78
+ description: 'Create a nullplatform application. GATE FIRST: if the request is bare and gives you NOTHING to infer from (e.g. just "create an app" / "create a new application" with no hint of what it\'s for or what to name it), DO NOT call this tool yet — first ask the user, in chat, what the app is for AND what to name it, and WAIT for both; call this only once you have the purpose (to pre-fill the namespace) AND the name in hand. Do NOT call it in the meantime, while still waiting on the name — opening a blank or half-filled form and continuing to ask is a failure, not a fallback. When you DO have context, INFER the best-fitting namespace and account from what the app is FOR (a demo → a demos/examples namespace, a backend service → a services namespace) and pass them as `namespace`/`account` so the form opens pre-selected on your inference — the user can still change it. Calling without `provision:true` opens an interactive form and creates nothing — the user confirms the namespace, then either a NEW repository (scaffolded from a template, repo URL auto-generated) or IMPORTS an existing repository (optionally a monorepo folder), and the form itself submits with provision:true to actually create. Only the form\'s submit sets provision:true; you (the model) NEVER set it and NEVER pass a repository_url to "pre-fill" expecting a create passing a repository_url without provision just opens the form, it does NOT write anything. Provisioning is async — the create waits briefly and reports progress.',
79
79
  annotations: { destructiveHint: false, openWorldHint: true },
80
80
  widget: "create-app",
81
81
  errorKey: "createApp.errorLabel",
@@ -94,6 +94,10 @@ export const createAppTool = defineTool({
94
94
  .string()
95
95
  .optional()
96
96
  .describe("Full git repo URL. New repos: auto-generated from the account + name. Import: the existing URL."),
97
+ template: z
98
+ .string()
99
+ .optional()
100
+ .describe('Template/stack to scaffold from — INFER it from what the app is FOR (a Go service → "go", a React frontend → "react"/"node", a Python API → "python") and pass the stack you would choose. The tool matches it against the chosen namespace\'s real templates and pre-selects it; prefer template_id only when you already know the exact id.'),
97
101
  template_id: z
98
102
  .number()
99
103
  .optional()
@@ -102,22 +106,28 @@ export const createAppTool = defineTool({
102
106
  .string()
103
107
  .optional()
104
108
  .describe("Import only: the folder inside a monorepo that holds this application"),
109
+ provision: z
110
+ .boolean()
111
+ .optional()
112
+ .describe("Set true ONLY to actually create the app + repository — this is the form's Create button. Omit it to open/pre-fill the form, even when you pass a repository_url: opening or pre-filling NEVER creates anything. You (the model) must NOT set this; the user confirms by submitting the form."),
105
113
  },
106
114
  async handler(args, context) {
107
- // Create ONLY on an explicit repository_url (the form's Create button, or a deliberate
108
- // call). With none, return the form and create nothing. create_app does NOT read the
109
- // ambient git remote: opening the form, switching namespace, or running the MCP inside some
110
- // repo must never auto-create an app behind the user's back (the bug where "let's create an
111
- // app" silently created the MCP's own repo). The user chooses the repository in the form.
115
+ // Create ONLY on an explicit provision:true (the form's Create button). The presence of a
116
+ // repository_url is NOT the trigger: the model routinely passes one to PRE-FILL the form, and
117
+ // treating that as "create now" silently provisioned an app the user never confirmed (the bug
118
+ // where the panel jumped straight to "Creating…"). So opening or pre-filling the form with or
119
+ // without a repository_url creates nothing; the user submits to create. create_app also does
120
+ // NOT read the ambient git remote, so running the MCP inside some repo never auto-creates either.
112
121
  const repoUrl = args.repository_url ? httpsRepoUrl(args.repository_url) : undefined;
113
- if (!repoUrl) {
122
+ if (!args.provision || !repoUrl) {
114
123
  return buildFormReply(context, {
115
124
  name: args.name,
116
125
  account: args.account,
117
126
  namespace: args.namespace,
118
127
  namespace_id: args.namespace_id,
128
+ template: args.template,
119
129
  template_id: args.template_id,
120
- repository_url: null,
130
+ repository_url: args.repository_url ?? null,
121
131
  });
122
132
  }
123
133
  // Convergent under retries: a repo already linked to an app returns that app, never a duplicate.
@@ -187,6 +197,8 @@ export const createAppTool = defineTool({
187
197
  name,
188
198
  account: args.account,
189
199
  namespace: args.namespace,
200
+ template: args.template,
201
+ template_id: args.template_id,
190
202
  repository_url: repoUrl,
191
203
  });
192
204
  }
@@ -20,7 +20,7 @@ const slug = (text) => text
20
20
  export const createLinkTool = defineTool({
21
21
  name: TOOL.applicationLinkCreate,
22
22
  title: "Link service",
23
- description: "Link a provisioned dependency service (a database, queue, cache, …) to an application or one of its scopes so its connection details flow in as read-only parameters. Pass `service` to choose which one (by name). A credential link (e.g. a Postgres database-user) provisions a user with the permissions you pick, so call WITHOUT `create:true` first to open the form; the user confirms by submitting it. New parameters reach the runtime on the NEXT deploy.",
23
+ description: 'LINK a provisioned dependency service (a database, queue, cache, …) to an application or one of its scopes so its connection details flow in as read-only parameters. Use it for "connect/link the database to <app/scope>", "wire up the queue". Pass `service` (by name) and optionally a `scope` (default: the whole app). A credential link (e.g. a Postgres database-user) provisions a user with the permissions you pick, so call WITHOUT `create:true` first to open the form; the user confirms by submitting. New parameters reach the runtime on the NEXT deploy. Provision the service first with application_service_create.',
24
24
  annotations: { destructiveHint: false, openWorldHint: true },
25
25
  widget: "service-link",
26
26
  errorKey: "createLink.errorLabel",
@@ -8,7 +8,7 @@ import { appArg, requireApp } from "./shared.js";
8
8
  export const createReleaseTool = defineTool({
9
9
  name: TOOL.applicationReleaseCreate,
10
10
  title: "Create release",
11
- description: "Cut an active release from a build (default: the latest successful build; semver auto-bumps the patch). Usually you can just call deploy, which does this for you.",
11
+ description: 'Cut an active RELEASE from a build (default: the latest successful build; semver auto-bumps the patch) — a release is a deployable, versioned pin of a build. Use it for "cut a release", "create a release from build X" WITHOUT deploying. Usually you do NOT need this directly: application_deployment_create cuts the release for you as part of shipping — reach for this only when the user explicitly wants a release but not a deploy. Convergent: reuses the existing active release for the same build rather than duplicating. Renders the releases panel; deploy a release with application_deployment_create.',
12
12
  // Convergent under retries (reuses the active release-for-build), so idempotent.
13
13
  annotations: { destructiveHint: false, idempotentHint: true, openWorldHint: true },
14
14
  widget: "releases",
@@ -9,7 +9,7 @@ import { buildAppStatus } from "./status.js";
9
9
  export const createScopeTool = defineTool({
10
10
  name: TOOL.applicationScopeCreate,
11
11
  title: "Create scope",
12
- description: "Create a scope (a deploy target, e.g. dev/staging/main) for an application. This provisions real infrastructure asynchronously. If the org has several scope types and none is given, it lists them to pick from.",
12
+ description: 'Create a SCOPE a deploy target (e.g. dev/staging/main) for an application; provisions real infrastructure asynchronously. Use it for "add a dev/staging/prod environment", "create a scope", "set up a new deploy target". Pass `name`; the scope `type` is inferred or auto-pinned when the org has one, else the form lists the org\'s types to pick from; `dimensions` default to the shape of an existing scope when the org uses them. Convergent: an existing scope of the same name is returned, never duplicated. Renders the app panel; once active, deploy to it with application_deployment_create scope:"<name>".',
13
13
  // Convergent under retries (reuses the scope-by-name), so idempotent.
14
14
  annotations: { destructiveHint: false, idempotentHint: true, openWorldHint: true },
15
15
  widget: "np-panel",
@@ -4,7 +4,7 @@ import { dashboardLink, linkLine, next, table } from "../md.js";
4
4
  import { createServiceAction, getService, listAppServices, listDependencySpecs, provisionService, serviceCreateActionSpec, } from "../np/journey.js";
5
5
  import { defineTool, fail, reply } from "../tool.js";
6
6
  import { TOOL } from "../tool-names.js";
7
- import { appArg, delays, requireApp, sleep } from "./shared.js";
7
+ import { appArg, delays, requireApp, resolveChoice, sleep } from "./shared.js";
8
8
  const slug = (text) => text
9
9
  .toLowerCase()
10
10
  .replace(/[^a-z0-9]+/g, "-")
@@ -66,23 +66,23 @@ export const createServiceTool = defineTool({
66
66
  return fail(translate("createService.noSpecs", { app: app.name }) +
67
67
  linkLine(translate("md.dashboard"), dashboard));
68
68
  }
69
- // Match the requested dependency: exact id (form submit) wins; else fuzzy `type`. None or
70
- // ambiguous return the catalog to pick from.
71
- const direct = args.specification_id
72
- ? specs.find((spec) => spec.id === args.specification_id)
73
- : undefined;
74
- const matches = direct ? [direct] : args.type ? matchSpecs(specs, args.type) : [];
75
- if (matches.length !== 1) {
76
- const list = matches.length > 1 ? matches : specs;
77
- const heading = translate(matches.length > 1 ? "createService.matchAmbiguous" : "createService.pickSpec", {
69
+ // Pre-select via the shared resolver (same rule as namespace/template): an exact spec id (form
70
+ // submit) or the model's `type` hint, matched against the catalog on name + category + sub-category.
71
+ // None / ambiguous → return the catalog (or the narrowed matches) to pick from — never an arbitrary one.
72
+ const spec = resolveChoice(specs, { id: args.specification_id, text: args.type }, {
73
+ haystack: (candidate) => `${candidate.name} ${candidate.category ?? ""} ${candidate.subCategory ?? ""}`,
74
+ });
75
+ if (!spec) {
76
+ const typed = args.type ? matchSpecs(specs, args.type) : [];
77
+ const list = typed.length > 1 ? typed : specs;
78
+ const heading = translate(typed.length > 1 ? "createService.matchAmbiguous" : "createService.pickSpec", {
78
79
  app: app.name,
79
80
  query: args.type ?? "",
80
81
  count: specs.length,
81
82
  });
82
- const md = `${heading}\n\n${table([translate("header.name"), translate("createService.category"), translate("createService.provider")], list.map((spec) => [spec.name, specCategory(spec), spec.provider ?? ""]))}${next(translate("createService.pickHint"))}`;
83
+ const md = `${heading}\n\n${table([translate("header.name"), translate("createService.category"), translate("createService.provider")], list.map((candidate) => [candidate.name, specCategory(candidate), candidate.provider ?? ""]))}${next(translate("createService.pickHint"))}`;
83
84
  return reply(md, { mode: "pick-spec", app: `#${app.id}`, app_name: app.name, specs: list, dashboard });
84
85
  }
85
- const spec = matches[0];
86
86
  const createAction = await serviceCreateActionSpec(context.np, spec.id);
87
87
  const name = (args.name ?? spec.name).trim();
88
88
  // Form-first: no explicit provision OR a required create-action param still missing → show the
@@ -19,7 +19,7 @@ const slug = (text) => text
19
19
  export const deleteLinkTool = defineTool({
20
20
  name: TOOL.applicationLinkDelete,
21
21
  title: "Delete link",
22
- description: "Delete a link between a service and an application, removing the parameters it exported. Pass `link` to choose which one (by name); call WITHOUT `confirm:true` first to preview, the user confirms by submitting. A credential link deprovisions its database user before the record is removed; a redeploy applies the parameter removal to the runtime.",
22
+ description: 'DELETE a link between a service and an application, removing the parameters it exported. Use it for "unlink/disconnect the <service> from <app>", "remove the link". Pass `link` (by name); call WITHOUT `confirm:true` first to open a confirm form (preview), the user confirms by submitting it. A credential link deprovisions its database user before the record is removed; a redeploy applies the parameter removal to the runtime. Destructive; nothing is removed without the submit.',
23
23
  annotations: { destructiveHint: true, idempotentHint: true, openWorldHint: true },
24
24
  widget: "service-delete",
25
25
  errorKey: "deleteEntity.linkErrorLabel",
@@ -13,7 +13,7 @@ import { appArg, requireApp } from "./shared.js";
13
13
  export const deleteParamTool = defineTool({
14
14
  name: TOOL.applicationParameterDelete,
15
15
  title: "Delete parameter",
16
- description: "Delete an application configuration parameter (the whole variable and all its values). Pass `name`; call WITHOUT `confirm:true` first to preview, the user confirms by submitting. Read-only parameters injected by a service link can't be deleted here — delete the link instead. Applies on the next deploy.",
16
+ description: 'DELETE an application configuration parameter the whole variable and ALL its values. Use it for "delete/remove the <NAME> env var/parameter". Pass `name`; call WITHOUT `confirm:true` first to open a confirm form (preview), the user confirms by submitting it. Read-only parameters injected by a service link can\'t be deleted here — delete the link instead (application_link_delete). Applies on the next deploy. To CHANGE a value instead of removing it, use application_parameter_create.',
17
17
  annotations: { destructiveHint: true, idempotentHint: true, openWorldHint: true },
18
18
  widget: "params",
19
19
  errorKey: "deleteParam.errorLabel",
@@ -19,7 +19,7 @@ const slug = (text) => text
19
19
  export const deleteServiceTool = defineTool({
20
20
  name: TOOL.applicationServiceDelete,
21
21
  title: "Delete service",
22
- description: "Delete a provisioned service and destroy its cloud resources. Pass `service` to choose which one (by name); call WITHOUT `confirm:true` first to see what will be destroyed, the user confirms by submitting. A service with active links can't be deleted — remove the links first (application_link_delete).",
22
+ description: 'DELETE a provisioned service and destroy its cloud resources. Use it for "delete/remove/tear down the <service>". Pass `service` (by name); call WITHOUT `confirm:true` first to open a confirm form showing what will be destroyed, the user confirms by submitting it. A service with active links can\'t be deleted — remove the links first with application_link_delete. Destructive and not reversible; nothing is destroyed without the submit.',
23
23
  annotations: { destructiveHint: true, idempotentHint: true, openWorldHint: true },
24
24
  widget: "service-delete",
25
25
  errorKey: "deleteEntity.serviceErrorLabel",
@@ -82,7 +82,7 @@ async function resolveRelease(context, applicationId, args) {
82
82
  export const deployTool = defineTool({
83
83
  name: TOOL.applicationDeploymentCreate,
84
84
  title: "Deploy",
85
- description: 'Ship an application: deploys the latest code with sensible defaults uses the latest successful build, creates the release for you (semver auto-bump) if needed, targets the app\'s only scope (or scope:"name"). Returns the live rollout view. For an app with traffic, follow with the traffic tool to move traffic over.',
85
+ description: 'DEPLOY an application provisions real infrastructure (starts a rollout), rendered as the live rollout panel. With no extra args it ships the latest code: picks the latest successful build, cuts the release for you (semver auto-bumped) if needed, and targets the app\'s only scope. Use it for "deploy", "ship it", "release to dev/prod". Narrow the target with `scope` (name or "environment=production"); pin an exact `release_id` or `version` (a known version rolls FORWARD or BACK to it); or cut from a specific `build_id`. For an app that splits traffic, FOLLOW with application_deployment_update to walk traffic to the new version; to undo, application_deployment_update action:"rollback". Convergent under retries: re-running returns the in-flight rollout for the same scope+release rather than creating a duplicate.',
86
86
  annotations: { destructiveHint: false, openWorldHint: true },
87
87
  widget: "np-panel",
88
88
  errorKey: "deploy.errorLabel",
@@ -13,7 +13,7 @@ import { appArg, offsetArg, pageOf, requireApp } from "./shared.js";
13
13
  export const deploymentsTool = defineTool({
14
14
  name: TOOL.applicationDeploymentList,
15
15
  title: "Deployments",
16
- description: "List an application's deployments across all scopes — which release landed on which scope, the rollout status, and age; a deployment group (one release to several scopes) is shown as one row. Use to see deploy history or find an active rollout.",
16
+ description: 'List ONE application\'s deployments across all its scopes, rendered as a PANEL — which release landed on which scope, the rollout status, and age (a deployment group landing one release on several scopes shows as one row). Use it for "deploy history", "what was deployed when", "find the active rollout". Does NOT deploy or change traffic (use application_deployment_create / application_deployment_update) and shows no logs/metrics. To find the newest deployment ACROSS apps, gather headlessly with entity_list (entity:"deployment") and compute it. Read-only.',
17
17
  annotations: { readOnlyHint: true, openWorldHint: true },
18
18
  widget: "deployments",
19
19
  errorKey: "deploymentList.errorLabel",
@@ -21,7 +21,7 @@ const ENTITIES = [
21
21
  export const entityGetTool = defineTool({
22
22
  name: TOOL.entityGet,
23
23
  title: "Read an entity (data)",
24
- description: 'Read-only, DATA-only fetch of ONE core entity by id — returns structuredContent and renders NO panel. Pass `entity` and `id` (e.g. entity:"application" id:123). Use this to drill into a specific entity while navigating with entity_list; use the rendered reads (application_get/…) when the USER wants to SEE it. Entities: organization, account, namespace, application, scope, build, release, deployment. (parameters/services/links/approvals have their own tools.)',
24
+ description: 'Read ONE entity by id as DATA — returns structuredContent and renders NO panel (no widget). Use it to (a) drill into a specific entity while navigating headlessly with entity_list, or (b) POLL a changing status WITHOUT spamming panels — loop entity_get entity:"build" id:<id> (or "deployment") until the status is final, instead of re-calling application_get each time. Pass `entity` + `id` (e.g. entity:"application" id:123). Entities: organization, account, namespace, application, scope, build, release, deployment. Use the rendered reads (application_get/…) when the USER wants to SEE it; parameters/services/links/approvals have their own tools.',
25
25
  annotations: { readOnlyHint: true },
26
26
  // No widget on purpose: data-only. See CLAUDE.md (the data-only read pattern).
27
27
  errorKey: "entityGet.errorLabel",
@@ -58,7 +58,7 @@ async function listByNrn(context, entity, nrn) {
58
58
  export const entityListTool = defineTool({
59
59
  name: TOOL.entityList,
60
60
  title: "List entities (data)",
61
- description: 'Read-only, DATA-only list of an entity scoped to its parent — returns structuredContent and renders NO panel, so you can navigate the org and gather context without putting a widget on the user\'s screen. REST tree: organization → account → namespace → application → {scope, build, release}; scope → deployment — pass `entity` plus the `parent_type`+`parent_id` it lives under (e.g. entity:"build" parent_type:"application" parent_id:123; listing `account` needs no parent). NRN-scoped resources (parameter, service, link, approval) live under an application or scope — pass parent_type:"application"|"scope" + parent_id (the NRN is resolved for you), or `nrn` directly. Use this to reason for yourself; use the rendered reads (application_list/application_build_list/…) when the USER wants to SEE the data. For one entity by id use entity_get.',
61
+ description: 'List releases, builds, deployments, scopes, applications, namespaces, parameters, services, links or approvals as DATA — returns structuredContent and renders NO panel. THIS is the tool for AGGREGATING or COMPARING across many apps/scopes: "the latest release across all my apps", "which app has a failing build", "every scope on the newest build" — list each app\'s releases/builds with this (no panel per app), then compute the answer yourself. Prefer it over application_release_list/application_build_list/application_deployment_list whenever you are gathering to reason or to answer an aggregate — those panel reads are ONLY for showing ONE app\'s list to the user. Scoped to a parent: REST tree organization → account → namespace → application → {scope, build, release}; scope → deployment — pass `entity` plus the `parent_type`+`parent_id` it lives under (e.g. entity:"release" parent_type:"application" parent_id:123; listing `account` needs no parent). NRN-scoped resources (parameter, service, link, approval) live under an application or scope — pass parent_type:"application"|"scope" + parent_id (the NRN is resolved for you), or `nrn` directly. For one entity by id use entity_get.',
62
62
  annotations: { readOnlyHint: true },
63
63
  // No widget on purpose: data-only navigation. See CLAUDE.md (the data-only read pattern).
64
64
  errorKey: "entityList.errorLabel",
@@ -8,13 +8,16 @@ import { TOOL } from "../tool-names.js";
8
8
  export const findAppsTool = defineTool({
9
9
  name: TOOL.applicationList,
10
10
  title: "Applications",
11
- description: "Search applications across the whole organization by partial name (and optionally namespace). Fast (parallel + cached). Use when the repo isn't linked or the user names an app you don't know.",
11
+ description: 'Search applications across the organization by partial name and/or namespace, rendered as a list PANEL grouped by namespace. Fast (parallel + cached). When the user asks for the apps IN a namespace ("my apps in pablov", "list the payments-ns apps"), ALWAYS pass `namespace` so the search is filtered at the source — never list the whole org and then filter the result yourself (the org can have hundreds of apps; post-filtering in code is wrong). Namespace matching is lenient (case- and space-insensitive: "pablov" matches "Pablo V"). Use `query` for a partial app name. Use this when the repo isn\'t linked or the user names an app/namespace you don\'t know.',
12
12
  annotations: { readOnlyHint: true, openWorldHint: true },
13
13
  widget: "find-apps",
14
14
  errorKey: "findApps.errorLabel",
15
15
  inputSchema: {
16
16
  query: z.string().optional().describe("Partial app name (case-insensitive). Omit to list everything."),
17
- namespace: z.string().optional().describe("Limit to namespaces whose name contains this"),
17
+ namespace: z
18
+ .string()
19
+ .optional()
20
+ .describe('Limit to a namespace by name — pass this whenever the user scopes to a namespace. Matched leniently (case- and space-insensitive): "pablov" or "pablo v" both match "Pablo V".'),
18
21
  limit: z.number().optional(),
19
22
  },
20
23
  async handler(args, context) {
@@ -8,7 +8,7 @@ import { appArg, chooseScope, requireApp } from "./shared.js";
8
8
  export const logsTool = defineTool({
9
9
  name: TOOL.applicationLogList,
10
10
  title: "Logs",
11
- description: "Read recent application logs (optionally for one scope). Returns the latest lines, newest last good for a quick 'why is it failing?' look.",
11
+ description: 'Read ONE application\'s recent logs (per scope), rendered as a logs PANEL — the latest lines, newest last. Use it for "why is it failing?", "show me the logs", "what errored". Logs are per-scope: omit `scope` to use the only one (or be asked which); page older lines with `page_token`; narrow the window with `start_time`/`end_time` (ISO-8601) or cap with `lines`. Does NOT return metrics or deploy status — use application_metric_list or application_get. Read-only.',
12
12
  annotations: { readOnlyHint: true, openWorldHint: true },
13
13
  widget: "logs",
14
14
  errorKey: "logs.errorLabel",
@@ -8,7 +8,7 @@ import { appArg, chooseScope, requireApp } from "./shared.js";
8
8
  export const metricsTool = defineTool({
9
9
  name: TOOL.applicationMetricList,
10
10
  title: "Performance metrics",
11
- description: "The golden signals of a scope — throughput (rpm), response time, error rate, CPU and memory — with sparkline trends over a time window. Use to answer 'how is it performing?' or to watch health during a rollout.",
11
+ description: 'The golden signals of ONE scope — throughput (rpm), response time, error rate, CPU, memory — with sparkline trends, rendered as a metrics PANEL. Use it for "how is it performing?", "is it healthy?", or to watch a scope during a rollout. Pick the `window` (1h/3h/24h/7d, default 3h); omit `scope` to use the only one. Does NOT return logs or deploy state — use application_log_list or application_get. Read-only.',
12
12
  annotations: { readOnlyHint: true, openWorldHint: true },
13
13
  widget: "metrics",
14
14
  errorKey: "metrics.errorLabel",
@@ -14,7 +14,7 @@ const MAX_APPS = 30;
14
14
  export const overviewTool = defineTool({
15
15
  name: TOOL.organizationGet,
16
16
  title: "Organization overview",
17
- description: "A cross-application health digest for the whole org: what's mid-rollout right now, and which scopes' last deployment failed or rolled back. Answers 'is anything broken?' / 'what's deploying?' without naming an app. Scans up to the first ~30 applications.",
17
+ description: 'A cross-application health digest for the WHOLE org, rendered as a panel: what\'s mid-rollout right now, and which scopes\' last deployment failed or rolled back. Use it for org-wide questions that name no app — "is anything broken?", "what\'s deploying?", "any failed rollouts?". Scans up to the first ~30 applications (truncation is reported, never silent). Does NOT show one app\'s full detail (use application_get), nor logs/metrics; to find the latest release or build ACROSS apps, gather headlessly with entity_list and compute it — don\'t call this. Read-only.',
18
18
  annotations: { readOnlyHint: true, openWorldHint: true },
19
19
  widget: "overview",
20
20
  errorKey: "overview.errorLabel",
@@ -24,7 +24,7 @@ function valueSummary(parameter, scopeNameById) {
24
24
  export const paramsTool = defineTool({
25
25
  name: TOOL.applicationParameterList,
26
26
  title: "Parameters",
27
- description: "List an application's configuration parameters and every value they hold across scopes and dimensions (env vars / files; secret values are masked). Pass `scope` to resolve the single effective value per parameter for that scope. Use application_parameter_create to add or change them.",
27
+ description: 'List ONE application\'s configuration parameters and EVERY value each holds across scopes and dimensions (env vars / files; secret values are masked, never shown in plaintext), rendered as a PANEL. Use it for "what env vars does this app have", "show the config", "what\'s DATABASE_URL set to". Pass `scope` to have the PLATFORM resolve the single effective value per parameter for that scope (it collapses scope value › most-specific dimension match › app default — never reimplement that precedence yourself). Does NOT change anything — use application_parameter_create to add/update, application_parameter_delete to remove. Read-only.',
28
28
  annotations: { readOnlyHint: true, openWorldHint: true },
29
29
  widget: "params",
30
30
  errorKey: "params.errorLabel",
@@ -14,7 +14,7 @@ const playbookBody = (markdown) => markdown.replace(/^---\n[\s\S]*?\n---\n?/, ""
14
14
  export const playbookGetTool = defineTool({
15
15
  name: TOOL.playbookGet,
16
16
  title: "Operating playbooks",
17
- description: "Read a nullplatform operating playbookthe methodology to follow BEFORE the matching non-trivial work (e.g. deploying-safely before a deploy, incident-response when something is broken, configuring-safely before changing parameters or secrets). The server instructions carry the full catalog; call this with no name to list them too.",
17
+ description: 'Read a nullplatform operating PLAYBOOKmodel-facing methodology for RISKY or MULTI-STEP work: deploying-safely (canary steps + metric gates for a production rollout), incident-response (triage when something is broken), configuring-safely (changing parameters or secrets carefully). Renders NO panel prose for YOU to consult, NOT a step you must run before every action. Reach for it only when you genuinely want the grounded procedure for a non-trivial operation; for a direct, simple command ("deploy", "set X", "roll back") just use that action\'s tool — do not detour here first. Call with no `name` to list the catalog (also in the server instructions).',
18
18
  annotations: { readOnlyHint: true },
19
19
  // No widget: a playbook is model-facing methodology prose, not an interactive entity — it
20
20
  // renders as text (the markdown below) for the model to read and act on. See CLAUDE.md.
@@ -12,7 +12,7 @@ import { appArg, offsetArg, pageOf, requireApp } from "./shared.js";
12
12
  export const releasesTool = defineTool({
13
13
  name: TOOL.applicationReleaseList,
14
14
  title: "Releases",
15
- description: "List an application's releases — semver, status (active/deprecated/unstable/failed), the build each pins, and age. Use to pick a release to deploy or promote, or to see what's shippable.",
15
+ description: "List ONE application's releases as a PANEL for the user — semver, status (active/deprecated/unstable/failed), the build each pins, and age. Use to pick a release to deploy or promote, or to see what's shippable. Do NOT call this once per app to compare across apps (it renders a panel each time): for an AGGREGATE like 'the latest release across all my apps' use entity_list (entity:\"release\"), gather headlessly, and compute the answer.",
16
16
  annotations: { readOnlyHint: true, openWorldHint: true },
17
17
  widget: "releases",
18
18
  errorKey: "releaseList.errorLabel",
@@ -13,7 +13,7 @@ import { appArg, requireApp } from "./shared.js";
13
13
  export const servicesTool = defineTool({
14
14
  name: TOOL.applicationServiceList,
15
15
  title: "Services & dependencies",
16
- description: "List the dependency services (databases, queues, caches…) attached to an application, plus the catalog of dependency types available to provision. Read-only: provisioning a new dependency is a guided flow, so this links you into the dashboard to create one.",
16
+ description: 'List the dependency services (databases, queues, caches…) attached to ONE application, plus the catalog of dependency types available to provision, rendered as a PANEL. Use it for "what databases/queues does this app use", "show its dependencies", "what can I provision". To provision a new dependency use application_service_create; to connect one to a scope use application_link_create. Read-only it does not provision or link anything itself.',
17
17
  annotations: { readOnlyHint: true, openWorldHint: true },
18
18
  widget: "services",
19
19
  errorKey: "services.errorLabel",
@@ -8,7 +8,7 @@ import { appArg, pickScope, requireApp } from "./shared.js";
8
8
  export const setParamsTool = defineTool({
9
9
  name: TOOL.applicationParameterCreate,
10
10
  title: "Set parameters",
11
- description: "Create or update application configuration parameters (environment variables or files; mark secrets). Target a value at the application (default — every scope), by dimension (applies to any matching scope), or pinned to one scope. Values apply on the NEXT deploy.",
11
+ description: 'Create or UPDATE application configuration parameters environment variables or files, secrets supported (stored masked, updatable; NOT write-once). A parameter is a value-SET: one definition holds many values keyed by target. Target a value at the APPLICATION (default — every scope), BY DIMENSION (e.g. environment=production — applies to any matching scope), or pinned to ONE scope (dimensions and a scope are mutually exclusive). Use it for "set DATABASE_URL", "add an env var", "change a secret", "set a prod-only value". Values apply on the NEXT deploy. Renders the parameters panel after applying. Convergent: reuses the existing definition and updates in place rather than duplicating. View current values with application_parameter_list; remove with application_parameter_delete.',
12
12
  annotations: { destructiveHint: false, idempotentHint: true, openWorldHint: true },
13
13
  widget: "params",
14
14
  errorKey: "setParams.errorLabel",
@@ -75,6 +75,49 @@ export async function requireApp(context, args) {
75
75
  }
76
76
  return { out: fail(notFoundMessage(resolution), { not_found: true }) };
77
77
  }
78
+ /**
79
+ * The ONE pre-selection rule for every create/fill form selector (namespace, template, service spec,
80
+ * scope type…). It turns the model's soft inference — an explicit id, or the free-text stack/type it
81
+ * reasoned out of the conversation — into a concrete option, or `undefined` when it's a genuine
82
+ * unhinted choice. It NEVER returns an arbitrary first entry: a wrong pre-selection is worse than an
83
+ * honest blank. Division of labour (see CLAUDE.md): the MODEL infers (it holds the context) and passes
84
+ * the hint as a tool arg; the TOOL resolves it here; the WIDGET only mirrors the result.
85
+ *
86
+ * Order, most to least certain: explicit id → exact name → every query token present in the option's
87
+ * haystack (fuzzy "sql database") → an option name contained in the hint ("Golang Hexagonal - API" ⊃
88
+ * the "golang-hexagonal" template) → with `pinOnly`, the sole candidate. `haystack` widens the
89
+ * searchable text per field (name + tags, name + category…); it defaults to the option's name.
90
+ */
91
+ export function resolveChoice(options, hint, settings = {}) {
92
+ if (hint.id != null) {
93
+ const byId = options.find((option) => option.id === hint.id);
94
+ if (byId)
95
+ return byId;
96
+ }
97
+ const query = hint.text?.trim().toLowerCase();
98
+ if (query) {
99
+ const haystackOf = settings.haystack ?? ((option) => option.name);
100
+ const exact = options.filter((option) => option.name.trim().toLowerCase() === query);
101
+ if (exact.length === 1)
102
+ return exact[0];
103
+ const tokens = query.split(/\s+/).filter(Boolean);
104
+ const tokenMatch = options.filter((option) => {
105
+ const haystack = haystackOf(option).toLowerCase();
106
+ return tokens.every((token) => haystack.includes(token));
107
+ });
108
+ if (tokenMatch.length === 1)
109
+ return tokenMatch[0];
110
+ const contained = options.filter((option) => {
111
+ const name = option.name.trim().toLowerCase();
112
+ return name.length >= 2 && query.includes(name);
113
+ });
114
+ if (contained.length === 1)
115
+ return contained[0];
116
+ }
117
+ if (settings.pinOnly && options.length === 1)
118
+ return options[0];
119
+ return undefined;
120
+ }
78
121
  /**
79
122
  * Match scopes by name OR by dimensions: "dev" (name substring), "production"
80
123
  * (dimension value substring), "environment=production" (exact key=value).
@@ -41,7 +41,7 @@ export async function buildAppStatus(context, app) {
41
41
  export const statusTool = defineTool({
42
42
  name: TOOL.applicationGet,
43
43
  title: "Application status",
44
- description: "THE place to start. Shows an application's full picture: scopes with what's live on each (release + traffic), latest build, latest release, and the one obvious next action. Call with no arguments inside a repo to use the linked app. Pass deployment:<id> to watch one rollout in detail.",
44
+ description: 'THE place to start for ONE application\'s live status — renders a status PANEL: each scope with what\'s live on it (release + traffic), the latest build, the latest release, and the single obvious next action. Use it for "what\'s the status of my app", "what\'s deployed where", "is it healthy" — a STATUS question. For a DIRECT action ("deploy", "send traffic", "show logs/metrics") reach for that action\'s own tool instead of starting here; only read status first when you genuinely need to see state before acting. Call with NO arguments inside a git repo to use the linked app; pass `app` (name or "#id") for a different one; pass `deployment:<id>` to watch one rollout\'s live detail. Does NOT return logs, metrics, parameters, or a build\'s assets — use application_log_list / application_metric_list / application_parameter_list / application_build_list for those. Do NOT loop this to WAIT for a build or deployment to finish: it repaints the whole panel every call (stacking duplicate panels) — poll headlessly with entity_get (entity:"build" or "deployment", id:<id>) instead, and show this panel once at the end.',
45
45
  annotations: { readOnlyHint: true, openWorldHint: true },
46
46
  widget: "np-panel",
47
47
  errorKey: "status.errorLabel",
@@ -9,7 +9,7 @@ import { appArg, delays, requireApp, sleep } from "./shared.js";
9
9
  export const trafficTool = defineTool({
10
10
  name: TOOL.applicationDeploymentUpdate,
11
11
  title: "Update traffic",
12
- description: 'Drive an in-flight rollout: move traffic to the new version (percent snaps to 0,1,5,10,25,50,75,90,95,99,100), finalize it (action:"finalize" retire the old version), or roll it back (action:"rollback" traffic returns to the old version). Finds the app\'s active deployment automatically.',
12
+ description: 'Drive an IN-FLIGHT rollout the step AFTER application_deployment_create. Call it DIRECTLY with `percent` (snaps to 0,1,5,10,25,50,75,90,95,99,100) to move traffic, action:"finalize" to retire the old version, or action:"rollback" to return traffic to the previous version (the safety hatch). You do NOT need to read status or look up a deployment id first — it finds the app\'s single active rollout automatically (pass `deployment_id` only when several are in flight at once). Use it for "send 25% traffic", "promote to 100%", "finalize", "roll back". Renders the live rollout panel. Does NOT create a deployment — use application_deployment_create first; this only steers one that already exists.',
13
13
  annotations: { destructiveHint: true, openWorldHint: true },
14
14
  widget: "np-panel",
15
15
  errorKey: "traffic.errorLabel",