@hoststack.dev/mcp 0.17.0 → 0.19.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.
@@ -8,7 +8,7 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
8
8
  import { HostStack } from "@hoststack.dev/sdk";
9
9
 
10
10
  // src/version.ts
11
- var MCP_VERSION = true ? "0.17.0" : "0.0.0-dev";
11
+ var MCP_VERSION = true ? "0.19.0" : "0.0.0-dev";
12
12
  var USER_AGENT = `hoststack-mcp/${MCP_VERSION}`;
13
13
 
14
14
  // src/api-client.ts
@@ -228,7 +228,7 @@ definePrompt({
228
228
  const text = `Goal: diagnose the most recent failed deploy on service ${service_id} and propose a fix.
229
229
 
230
230
  Plan (use these tools in order):
231
- 1. \`list_deploys({ service_id: "${service_id}" })\` \u2014 pull recent deploys, newest first. Identify the most recent deploy with status \`failed\` (or \`cancelled\` if the user wants to investigate that too). Capture its publicId, branch, and commitSha.
231
+ 1. \`list_deploys({ service_id: "${service_id}" })\` \u2014 pull recent deploys, newest first. Identify the most recent deploy with status \`failed\` (or \`cancelled\` if the user wants to investigate that too). Capture its publicId, branch, and commitHash.
232
232
  2. \`get_deploy_logs({ service_id: "${service_id}", deploy_id: "<dpl_\u2026>" })\` \u2014 read the full build output. Scan for: stack traces, "ERROR" / "error:" lines, exit codes, missing env vars, OOM-killed signals, network failures pulling dependencies.
233
233
  3. \`get_service({ service_id: "${service_id}" })\` \u2014 confirm the service's runtime, plan, and whether autoDeploy is on. Plan tier matters because OOMs at small tiers point at \`maxMemoryMb\`.
234
234
  4. (Optional) \`list_env_vars({ service_id: "${service_id}" })\` \u2014 only if the build error mentions a missing variable. Don't fetch otherwise; values are masked anyway.
@@ -510,9 +510,9 @@ defineTool({
510
510
  name: "list_alerts",
511
511
  category: "alerts",
512
512
  description: [
513
- "List recent alert-shaped events for the team: deploy failures, git auth losses, service health failures, auto-restarts, ACME cert failures, resource alerts (high CPU), database backup/restore failures, registrant verification lapses.",
513
+ "List recent alert-shaped events for the team: deploy failures, git auth losses, service health failures, uptime failures (a service stopped answering its public URL), new and regressed error issues (an exception the app reported), auto-restarts, ACME cert failures, resource alerts (high CPU), database backup/restore failures, registrant verification lapses.",
514
514
  "",
515
- "When to use: triage 'what's currently broken or recently broke for this team'. Pairs with list_activity_log for the full audit feed; this tool is the alert-shaped subset.",
515
+ "When to use: triage 'what's currently broken or recently broke for this team'. Pairs with list_activity_log for the full audit feed; this tool is the alert-shaped subset. For an `error.issue_new` / `error.issue_regressed` entry, `list_error_issues` and `get_error_issue` carry the stack behind it.",
516
516
  "",
517
517
  'By default events are AGGREGATED by (action, resourceId) so flapping events collapse to one row with a fire count + first/last timestamps \u2014 e.g. "service.auto_restarted on service 31, 8 times in the last hour, last at 14:22". Pass aggregate=false to see every raw row.',
518
518
  "",
@@ -1146,7 +1146,7 @@ defineTool({
1146
1146
  "Inputs:",
1147
1147
  ' - service_id: publicId of the service (e.g. "svc_abc123"). Use list_services to find it.',
1148
1148
  "",
1149
- 'Returns: { items: Deploy[] } \u2014 each deploy includes id, publicId, status (pending|building|deploying|live|failed|cancelled), commitSha, commitMessage, branch, triggeredBy, startedAt, finishedAt, imageBuildMs (docker build / image-pull only; null on skip-build redeploys \u2014 v89), containerBootMs (deploying \u2192 live wall-clock; null on builds that failed before container start \u2014 v89), buildDurationMs (legacy alias of imageBuildMs kept for back-compat), totalDurationMs (full deploy wall-clock = finishedAt \u2212 startedAt). Use imageBuildMs + containerBootMs together to tell "build is slow" apart from "boot is slow".',
1149
+ 'Returns: { items: Deploy[] } \u2014 each deploy includes id, publicId, status (pending|building|deploying|live|failed|cancelled), commitHash (the resolved commit SHA; the key is OMITTED entirely when the deploy recorded none, so a missing key means "no commit", not "wrong key"), commitMessage, branch, triggeredBy, startedAt, finishedAt, imageBuildMs (docker build / image-pull only; null on skip-build redeploys \u2014 v89), containerBootMs (deploying \u2192 live wall-clock; null on builds that failed before container start \u2014 v89), buildDurationMs (legacy alias of imageBuildMs kept for back-compat), totalDurationMs (full deploy wall-clock = finishedAt \u2212 startedAt). Use imageBuildMs + containerBootMs together to tell "build is slow" apart from "boot is slow".',
1150
1150
  "",
1151
1151
  'Example: list_deploys({ service_id: "svc_abc" }) \u2192 { items: [{ publicId: "dpl_\u2026", status: "live", commitMessage: "Fix login", \u2026 }, \u2026] }'
1152
1152
  ].join("\n"),
@@ -1173,7 +1173,7 @@ defineTool({
1173
1173
  " - service_id: publicId of the service.",
1174
1174
  ' - deploy_id: publicId of the deploy (e.g. "dpl_\u2026").',
1175
1175
  "",
1176
- "Returns: { deploy: Deploy } \u2014 full deploy record (status, commitSha, commitMessage, branch, imageBuildMs + containerBootMs (v89 split timings; legacy buildDurationMs preserved as alias), totalDurationMs, finishedAt, etc).",
1176
+ "Returns: { deploy: Deploy } \u2014 full deploy record (status, commitHash (omitted when the deploy recorded no commit), commitMessage, branch, imageBuildMs + containerBootMs (v89 split timings; legacy buildDurationMs preserved as alias), totalDurationMs, finishedAt, etc).",
1177
1177
  "",
1178
1178
  'Example: get_deploy({ service_id: "svc_abc", deploy_id: "dpl_xyz" }) \u2192 { deploy: { status: "live", \u2026 } }'
1179
1179
  ].join("\n"),
@@ -1393,7 +1393,7 @@ var DNS_RECORD_TYPES = [
1393
1393
  async function resolveZonePublicId(hoststack, teamId, input) {
1394
1394
  if (input.zone_id) {
1395
1395
  const { zones: zones2 } = await hoststack.dns.listZones(teamId);
1396
- const match = zones2.find((z17) => z17.publicId === input.zone_id);
1396
+ const match = zones2.find((z22) => z22.publicId === input.zone_id);
1397
1397
  if (!match) {
1398
1398
  throw new Error(`Zone ${input.zone_id} not found on this team.`);
1399
1399
  }
@@ -1407,7 +1407,7 @@ async function resolveZonePublicId(hoststack, teamId, input) {
1407
1407
  const labels = fqdn.split(".");
1408
1408
  for (let i = 0; i < labels.length - 1; i++) {
1409
1409
  const candidate = labels.slice(i).join(".");
1410
- const match = zones.find((z17) => z17.domainName.toLowerCase() === candidate);
1410
+ const match = zones.find((z22) => z22.domainName.toLowerCase() === candidate);
1411
1411
  if (match && match.status !== "deleting") {
1412
1412
  return { publicId: match.publicId, domainName: match.domainName };
1413
1413
  }
@@ -1868,6 +1868,7 @@ defineTool({
1868
1868
  " - value: new value (will be encrypted at rest if is_secret=true).",
1869
1869
  " - is_secret (optional): true marks the value as secret (masked on read). On create, defaults to true for safety. On update, omitting it leaves the existing flag untouched \u2014 pass it explicitly only when you want to change classification.",
1870
1870
  ' - target (optional): where the var is injected \u2014 "build", "runtime", or "both". On create, defaults to "both". On update, omitting it leaves the existing target untouched.',
1871
+ ' IMPORTANT for build-time values (a frontend VITE_*/NEXT_PUBLIC_*, anything a bundler inlines): "both" and "build" reach the build, but a SECRET var never does on "both" \u2014 build args are baked into image history. Since is_secret defaults to TRUE here, set is_secret:false for a public build-time value or it will silently be missing from the built artifact. Use "build" to force a secret into the build anyway.',
1871
1872
  "",
1872
1873
  'Returns: { envVar: EnvVar, action: "created" | "updated" }.',
1873
1874
  "",
@@ -1878,7 +1879,9 @@ defineTool({
1878
1879
  key: z10.string().min(1).max(128).describe("Env-var key."),
1879
1880
  value: z10.string().describe("New value."),
1880
1881
  is_secret: z10.boolean().optional().describe("Mark as secret (encrypted, masked on read). Default true."),
1881
- target: z10.enum(["build", "runtime", "both"]).optional().describe("Injection target: build, runtime, or both. Default both.")
1882
+ target: z10.enum(["build", "runtime", "both"]).optional().describe(
1883
+ 'Injection target: build, runtime, or both. Default both. A secret var on "both" is runtime-only (build args land in image history) \u2014 pass is_secret:false for a public build-time value, or use "build" to force it.'
1884
+ )
1882
1885
  },
1883
1886
  handler: async (args2, ctx) => {
1884
1887
  const teamId = await ctx.resolveTeamId();
@@ -1961,6 +1964,7 @@ defineTool({
1961
1964
  "Inputs:",
1962
1965
  " - service_id: publicId of the service.",
1963
1966
  ' - env_vars: array of { key, value, is_secret?, target? }. is_secret defaults to FALSE per row (a row is stored in the clear unless you set is_secret:true); target defaults to "both". Note this differs from set_env_var, which defaults a newly-created var to secret.',
1967
+ ' A var reaches the image BUILD when target is "build", or when target is "both" and is_secret is false. A secret on "both" is runtime-only, because build args are visible in image history.',
1964
1968
  "",
1965
1969
  "Returns: { ok: true }. Re-list with list_env_vars to confirm the new state.",
1966
1970
  "",
@@ -2150,6 +2154,531 @@ defineTool({
2150
2154
  }
2151
2155
  });
2152
2156
 
2157
+ // src/tools/analytics.ts
2158
+ import { z as z12 } from "zod";
2159
+ async function resolveSiteIds(domains, teamId, api) {
2160
+ if (!domains || domains.length === 0) return void 0;
2161
+ const { sites } = await api.get(`/api/analytics/${teamId}/sites`);
2162
+ const ids = [];
2163
+ const missing = [];
2164
+ for (const domain of domains) {
2165
+ const needle = domain.toLowerCase().replace(/^https?:\/\//, "").replace(/\/.*$/, "");
2166
+ const match = sites.find((s) => s.domain === needle);
2167
+ if (match) ids.push(match.id);
2168
+ else missing.push(domain);
2169
+ }
2170
+ if (missing.length > 0) {
2171
+ throw new Error(
2172
+ `Not tracking ${missing.join(", ")}. Known sites: ${sites.map((s) => s.domain).join(", ") || "none yet"}.`
2173
+ );
2174
+ }
2175
+ return ids.join(",");
2176
+ }
2177
+ defineTool({
2178
+ name: "list_analytics_sites",
2179
+ category: "analytics",
2180
+ description: [
2181
+ "List the websites this team tracks \u2014 domains, their site keys, retention, and which service (if any) serves each one.",
2182
+ "",
2183
+ "When to use: before any other analytics call, to learn which domains exist; or to fetch the script tag a site needs.",
2184
+ "",
2185
+ "The site key is PUBLIC by design \u2014 it ships in a `data-site-key` attribute that anyone can view-source, like a Sentry DSN. It is write-only and scoped to one site. Do not treat it as a secret, and do not confuse it with the team API key.",
2186
+ "",
2187
+ "Returns: { sites: Array }. Each site has id, publicId, name, domain, ingestKey, allowedOrigins, serviceId, serviceName, retentionDays, droppedCount (events refused by the hourly quota and therefore MISSING from every count for that site), and keyGraceEndsAt when a rotation is still in its grace window.",
2188
+ "",
2189
+ 'Each site also carries lastEventAt (null means NOTHING has ever reached ingest for that key) plus lastRefusalReason and refusedRecently, a 7-day tally by reason. An empty chart with lastEventAt set is a site with no traffic; an empty chart with lastEventAt null is a broken setup. Do not report "no visitors" without checking which one you are looking at \u2014 call check_analytics_site for the diagnosis.',
2190
+ "",
2191
+ "Example: list_analytics_sites({}) \u2192 { sites: [{ id: 3, domain: 'micci.dk', ingestKey: 'site_\u2026', retentionDays: 30, serviceName: 'micci-web' }] }."
2192
+ ].join("\n"),
2193
+ input: {},
2194
+ handler: async (_args, ctx) => {
2195
+ const teamId = await ctx.resolveTeamId();
2196
+ const response = await ctx.api.get(`/api/analytics/${teamId}/sites`);
2197
+ const items = Array.isArray(response.sites) ? response.sites.map(shape) : [];
2198
+ const summary = items.length === 0 ? "No analytics sites yet. Create one with create_analytics_site." : `Tracking ${items.length} site${items.length === 1 ? "" : "s"}.`;
2199
+ return respond({ summary, data: { sites: items } });
2200
+ }
2201
+ });
2202
+ defineTool({
2203
+ name: "check_analytics_site",
2204
+ category: "analytics",
2205
+ description: [
2206
+ "Why a site is reporting nothing \u2014 the diagnosis, without having to generate traffic and guess.",
2207
+ "",
2208
+ "When to use: any time a chart is empty, a site was just set up, a key was rotated, or someone asks whether the snippet is installed correctly. Call this BEFORE telling anyone their traffic dropped.",
2209
+ "",
2210
+ "The ingest endpoint answers HTTP 204 to a real site key and to a typo alike \u2014 deliberately, so it cannot be used to enumerate keys \u2014 which means five completely different situations produce the same empty chart: a stale or mistyped key in the deployed snippet, an origin the site does not allow, a blown hourly quota, events blocked in the browser before they leave (CORS, ad blocker, CSP), and simply no visitors yet. This tool tells them apart.",
2211
+ "",
2212
+ "Returns: health ('receiving' | 'quiet' | 'refusing' | 'never'), a headline and detail sentence, lastEventAt, lastRefusalAt/Reason/Origin, refusedRecently (a 7-day tally by reason), the site key the snippet must carry, allowed origins, and quota usage this hour.",
2213
+ "",
2214
+ "health='never' means nothing has EVER reached ingest for this key, so the request is not leaving the browser \u2014 check the snippet is in the deployed HTML and tell the user to add `data-debug` to the script tag, which makes the tracker log its resolved endpoint and every accepted 204 to the console.",
2215
+ "",
2216
+ "Example: check_analytics_site({ domain: 'example.com' }) \u2192 { health: 'refusing', headline: 'Reaching us, and being refused.', detail: 'Something on example.com posted 214 event(s) with a site key we do not recognise\u2026', lastEventAt: null, refusedRecently: { unknown_key: 214, bad_origin: 0, bot: 3, over_quota: 0 } }."
2217
+ ].join("\n"),
2218
+ input: {
2219
+ domain: z12.string().max(253).describe("Bare hostname of a site this team tracks.")
2220
+ },
2221
+ handler: async (args2, ctx) => {
2222
+ const teamId = await ctx.resolveTeamId();
2223
+ const siteIds = await resolveSiteIds([args2.domain], teamId, ctx.api);
2224
+ const status = await ctx.api.get(
2225
+ `/api/analytics/${teamId}/sites/${siteIds}/status`
2226
+ );
2227
+ return respond({ summary: `${args2.domain}: ${status.headline}`, data: shape(status) });
2228
+ }
2229
+ });
2230
+ defineTool({
2231
+ name: "get_analytics_summary",
2232
+ category: "analytics",
2233
+ description: [
2234
+ "One row per site: visitors, pageviews, bounce rate, average visit and live visitors, each with the previous period to compare against.",
2235
+ "",
2236
+ 'When to use: "how are my sites doing", a weekly check, or spotting which of several domains moved. This is the cross-site view; use get_analytics_overview for the breakdowns behind one of them.',
2237
+ "",
2238
+ "READ THE VISITOR NUMBER CAREFULLY. Inside a site's raw-event window (35 days by default, so 24h through 30d are raw) `visitors` is a true distinct count over the range. Beyond it the answer comes from a daily rollup and `visitors` is each day's uniques ADDED TOGETHER \u2014 someone who visits daily counts once per day. `visitorsAreSummedDailies` says which you got; quote it when reporting a 90d or 12mo number.",
2239
+ "",
2240
+ "Visitors are never summed ACROSS sites. The same person on two domains is two session ids, and reconciling them would require the cross-domain identity a cookieless tracker exists not to build. Add pageviews if you need a total; do not add visitors.",
2241
+ "",
2242
+ "Inputs: range (24h|7d|30d|90d|12mo, default 7d), domains (optional list; omit for every site).",
2243
+ "",
2244
+ "Returns: the range asked for, and one row per site \u2014 domain, `current` and `previous` blocks of visitors / pageviews / bounceRate / avgVisitSeconds, the live visitor count, and `visitorsAreSummedDailies`, which says whether that visitor number is a distinct count or a sum of daily uniques. Quote that flag with any 90d or 12mo figure.",
2245
+ "",
2246
+ "Example: get_analytics_summary({ range: '30d' }) \u2192 { range: '30d', sites: [{ domain: 'hoststack.dev', current: { visitors: 4210, pageviews: 11890, bounceRate: 47.2 }, previous: {\u2026}, live: 3, visitorsAreSummedDailies: false }] }."
2247
+ ].join("\n"),
2248
+ input: {
2249
+ range: z12.enum(["24h", "7d", "30d", "90d", "12mo"]).optional().describe("Default 7d."),
2250
+ domains: z12.array(z12.string().max(253)).max(50).optional().describe("Bare hostnames. Omit for every site this team tracks.")
2251
+ },
2252
+ handler: async (args2, ctx) => {
2253
+ const teamId = await ctx.resolveTeamId();
2254
+ const siteIds = await resolveSiteIds(args2.domains, teamId, ctx.api);
2255
+ const response = await ctx.api.get(`/api/analytics/${teamId}/summary`, {
2256
+ range: args2.range ?? "7d",
2257
+ ...siteIds ? { siteIds } : {}
2258
+ });
2259
+ const sites = Array.isArray(response.sites) ? response.sites : [];
2260
+ const summed = sites.some((s) => s.visitorsAreSummedDailies);
2261
+ const summary = sites.length === 0 ? "No sites to report on." : `${sites.length} site${sites.length === 1 ? "" : "s"} over ${response.range}.${summed ? " Visitor counts are daily uniques summed, not distinct over the range \u2014 this range is past the raw-event window." : ""}`;
2262
+ return respond({ summary, data: { range: response.range, sites: sites.map(shape) } });
2263
+ }
2264
+ });
2265
+ defineTool({
2266
+ name: "get_analytics_overview",
2267
+ category: "analytics",
2268
+ description: [
2269
+ "The full breakdown for one site (or several): timeseries, top paths, referrers, custom events, browsers, operating systems, languages, screen sizes, campaigns, devices and countries.",
2270
+ "",
2271
+ "When to use: after get_analytics_summary has told you WHICH site moved, and you need to see what drove it \u2014 a referrer that appeared, a campaign that landed, a path that spiked.",
2272
+ "",
2273
+ "Two things change past the raw-event window (35 days by default, so 24h through 30d are raw): `visitors` becomes daily uniques summed rather than a distinct count (`visitorsAreSummedDailies`), and FILTERS ARE IGNORED because the rollup stores per-day aggregates with no events left to filter (`filtersSupported: false`). Check both fields before drawing a conclusion; do not silently present a filtered request that was not filtered.",
2274
+ "",
2275
+ "Inputs: range (24h|7d|30d|90d|12mo, default 7d), domains (optional list; omit for every site), filters (optional map \u2014 country, browser, os, device, language, path, referrer, utmSource, utmMedium, utmCampaign, utmTerm, utmContent).",
2276
+ "",
2277
+ "Returns: `source` ('raw' | 'rollup') and `filtersSupported`, which together say how much of the request was actually honoured; a `summary` block of current-vs-previous totals; a timeseries; and the breakdowns \u2014 topPaths, topReferrers, topEvents, browsers, operatingSystems, languages, screenSizes, campaigns, devices and countries. Read `source` and `filtersSupported` before quoting any of it.",
2278
+ "",
2279
+ "Example: get_analytics_overview({ domains: ['hoststack.dev'], range: '7d', filters: { country: 'DK' } }) \u2192 { source: 'raw', filtersSupported: true, summary: { current: { visitors: 310, \u2026 } }, topPaths: [{ path: '/pricing', pageviews: 214 }], topReferrers: [{ referrer: 'Direct', visits: 190 }], \u2026 }."
2280
+ ].join("\n"),
2281
+ input: {
2282
+ range: z12.enum(["24h", "7d", "30d", "90d", "12mo"]).optional().describe("Default 7d."),
2283
+ domains: z12.array(z12.string().max(253)).max(50).optional().describe("Bare hostnames. Omit for every site this team tracks."),
2284
+ filters: z12.record(z12.string(), z12.string().max(200)).optional().describe("Dimension filters. Ignored for ranges past the raw-event window.")
2285
+ },
2286
+ handler: async (args2, ctx) => {
2287
+ const teamId = await ctx.resolveTeamId();
2288
+ const siteIds = await resolveSiteIds(args2.domains, teamId, ctx.api);
2289
+ const response = await ctx.api.get(`/api/analytics/${teamId}/overview`, {
2290
+ range: args2.range ?? "7d",
2291
+ ...siteIds ? { siteIds } : {},
2292
+ ...args2.filters ?? {}
2293
+ });
2294
+ const { current } = response.summary;
2295
+ const caveats = [];
2296
+ if (response.visitorsAreSummedDailies) {
2297
+ caveats.push(
2298
+ "visitors are daily uniques summed (this range is past the raw-event window)"
2299
+ );
2300
+ }
2301
+ if (args2.filters && Object.keys(args2.filters).length > 0 && !response.filtersSupported) {
2302
+ caveats.push(
2303
+ "the filters you passed were NOT applied \u2014 they only work on shorter ranges"
2304
+ );
2305
+ }
2306
+ const summary = `${current.pageviews.toLocaleString()} pageviews from ${current.visitors.toLocaleString()} visitors over ${response.range}${caveats.length > 0 ? `. Note: ${caveats.join("; ")}.` : "."}`;
2307
+ return respond({ summary, data: response });
2308
+ }
2309
+ });
2310
+ defineTool({
2311
+ name: "create_analytics_site",
2312
+ category: "analytics",
2313
+ description: [
2314
+ "Start tracking a domain, and return the script tag to paste into its <head>.",
2315
+ "",
2316
+ "When to use: the user wants analytics for a site that is not in list_analytics_sites yet. Works for any domain, whether or not it is hosted on HostStack.",
2317
+ "",
2318
+ "If the domain is already attached to a service on this team, the new site links itself to that service and appears on its Analytics tab. Nothing is counted until the snippet is actually on the page \u2014 creating the site alone produces an empty dashboard, which is expected, not a fault.",
2319
+ "",
2320
+ 'Inputs: domain (required, bare hostname \u2014 "example.com", not a URL), name (optional display name, defaults to the domain).',
2321
+ "",
2322
+ "Returns: the created site (id, domain, name, ingestKey, retentionDays) and the ready-made `snippet` to paste into the page \u2014 the key is in that snippet, so hand it over rather than describing it.",
2323
+ "",
2324
+ `Example: create_analytics_site({ domain: 'micci.dk' }) \u2192 { site: { id: 3, domain: 'micci.dk', ingestKey: 'site_\u2026' }, snippet: '<script defer src="https://hoststack.dev/t.js" data-site-key="site_\u2026"></script>' }.`
2325
+ ].join("\n"),
2326
+ input: {
2327
+ domain: z12.string().min(1).max(253).describe("Bare hostname, e.g. example.com"),
2328
+ name: z12.string().min(1).max(100).optional().describe("Display name. Defaults to the domain.")
2329
+ },
2330
+ handler: async (args2, ctx) => {
2331
+ const teamId = await ctx.resolveTeamId();
2332
+ const site = await ctx.api.post(`/api/analytics/${teamId}/sites`, {
2333
+ domain: args2.domain,
2334
+ ...args2.name ? { name: args2.name } : {}
2335
+ });
2336
+ const snippet = `<script defer src="https://hoststack.dev/t.js" data-site-key="${site.ingestKey}"></script>`;
2337
+ return respond({
2338
+ summary: `Now tracking ${site.domain}. Paste the snippet into its <head> \u2014 nothing is counted until you do.`,
2339
+ data: { site: shape(site), snippet }
2340
+ });
2341
+ }
2342
+ });
2343
+ defineTool({
2344
+ name: "verify_site_domain",
2345
+ category: "analytics",
2346
+ description: [
2347
+ "Prove the team owns a site's domain, by publishing a TXT record.",
2348
+ "",
2349
+ "When to use: before turning on an uptime check for a site that HostStack does not host. Analytics itself needs no proof \u2014 the ingest key only labels events the site posts about itself \u2014 so do NOT send a user through this just to get a dashboard. It exists for the capabilities where the PLATFORM acts on the hostname.",
2350
+ "",
2351
+ "Call it once with no `check` to get the record to publish, then again with check=true once the record is live. The token is stable across calls, so it is safe to re-read the instructions while the user is mid-paste.",
2352
+ "",
2353
+ "A site whose domain is already verified on a service in this team is proven ALREADY and does not need this \u2014 `list_analytics_sites` reports that as domainProven. This is for the site with no service behind it, which can never have such a link.",
2354
+ "",
2355
+ `The record is read from the domain's own authoritative nameservers rather than through a cache, so "I just added it" works immediately instead of being masked by a negative-cache TTL.`,
2356
+ "",
2357
+ 'Inputs: siteId (required), check (optional, default false \u2014 true means "look at DNS now and tell me the verdict").',
2358
+ "",
2359
+ "Returns: the record to publish plus `verified`. A `verified: false` is a successful check with a negative answer, not an error \u2014 most often the record simply has not propagated yet.",
2360
+ "",
2361
+ "Example: verify_site_domain({ siteId: 3 }) \u2192 { recordName: '_hoststack-verify.poststack.dev', recordType: 'TXT', recordValue: 'hoststack-verify=9f3c\u2026', verified: false }."
2362
+ ].join("\n"),
2363
+ input: {
2364
+ siteId: z12.number().int().positive(),
2365
+ check: z12.boolean().optional().describe(
2366
+ "True to read DNS now and return the verdict. False just returns the record."
2367
+ )
2368
+ },
2369
+ handler: async (args2, ctx) => {
2370
+ const teamId = await ctx.resolveTeamId();
2371
+ const instructions = await ctx.api.get(`/api/analytics/${teamId}/sites/${args2.siteId}/verification`);
2372
+ if (!args2.check) {
2373
+ return respond({
2374
+ summary: instructions.verified ? "Already verified." : `Publish a ${instructions.recordType} record at ${instructions.recordName} with the value shown, then call again with check=true.`,
2375
+ data: shape(instructions)
2376
+ });
2377
+ }
2378
+ const verdict = await ctx.api.post(
2379
+ `/api/analytics/${teamId}/sites/${args2.siteId}/verify`,
2380
+ {}
2381
+ );
2382
+ return respond({
2383
+ summary: verdict.verified ? "Verified. This site can now be given an uptime check." : `Not verified yet \u2014 ${verdict.detail ?? "the record was not found"}.`,
2384
+ data: { record: shape(instructions), result: shape(verdict) }
2385
+ });
2386
+ }
2387
+ });
2388
+ defineTool({
2389
+ name: "set_site_uptime_check",
2390
+ category: "analytics",
2391
+ description: [
2392
+ "Watch a site HostStack does not host \u2014 request its URL on a schedule and alert when it stops answering.",
2393
+ "",
2394
+ "When to use: the user has a site running somewhere else (their own box, another provider) and wants to know when it goes down. This is the one observability capability an off-platform site cannot provide for itself: analytics is a script tag and error reporting is an HTTP POST, but an outside-in probe has to come from outside.",
2395
+ "",
2396
+ "For a site that IS a HostStack service, use set_uptime_check with its serviceId instead \u2014 that one follows the service if its domain changes.",
2397
+ "",
2398
+ "REQUIRES a proven domain. Call verify_site_domain first if `domainProven` is false; an unverified target is refused with 400. This is not paperwork: an uptime check makes the control plane fetch the hostname every interval, forever, from our IP, so it must be a name the team has shown it owns.",
2399
+ "",
2400
+ "Always https, and the host is the site's domain \u2014 `path` is a path, not a URL. Redirects are NOT followed: a 301 is an answer, and following one can walk the probe onto a parked page and report a dead site as healthy. Set expectedStatus to the redirect if one is expected.",
2401
+ "",
2402
+ 'Inputs (all optional except siteId): enabled, path (default "/"), method (GET|HEAD), expectedStatus (default 200), timeoutMs (1000\u201360000, default 10000 \u2014 this is what catches a hung server), intervalSeconds (30\u20133600, default 60), failureThreshold (1\u201310, default 3).',
2403
+ "",
2404
+ "Returns: the stored check \u2014 enabled, the resolved target URL, method, expectedStatus, timeoutMs, intervalSeconds, failureThreshold \u2014 plus its live state: status ('up' | 'down' | 'unknown'), lastCheckedAt, lastStatusCode and consecutiveFailures. A freshly created check is 'unknown' until the first probe runs.",
2405
+ "",
2406
+ "Example: set_site_uptime_check({ siteId: 3, path: '/health', intervalSeconds: 60 }) \u2192 { check: { status: 'unknown', \u2026 } }."
2407
+ ].join("\n"),
2408
+ input: {
2409
+ siteId: z12.number().int().positive(),
2410
+ enabled: z12.boolean().optional(),
2411
+ path: z12.string().max(500).optional().describe('Must start with /. Default "/".'),
2412
+ method: z12.enum(["GET", "HEAD"]).optional(),
2413
+ expectedStatus: z12.number().int().min(100).max(599).optional(),
2414
+ timeoutMs: z12.number().int().min(1e3).max(6e4).optional(),
2415
+ intervalSeconds: z12.number().int().min(30).max(3600).optional(),
2416
+ failureThreshold: z12.number().int().min(1).max(10).optional()
2417
+ },
2418
+ handler: async (args2, ctx) => {
2419
+ const teamId = await ctx.resolveTeamId();
2420
+ const { siteId, ...body } = args2;
2421
+ const response = await ctx.api.put(
2422
+ `/api/analytics/${teamId}/sites/${siteId}/uptime-check`,
2423
+ body
2424
+ );
2425
+ return respond({
2426
+ summary: "Uptime check saved. It will start reporting within a minute or two.",
2427
+ data: { check: shape(response.check) }
2428
+ });
2429
+ }
2430
+ });
2431
+
2432
+ // src/tools/errors.ts
2433
+ import { z as z13 } from "zod";
2434
+ defineTool({
2435
+ name: "list_error_issues",
2436
+ category: "errors",
2437
+ description: [
2438
+ "List error issues for the team \u2014 exceptions the team's own applications reported, grouped by cause rather than listed one per event.",
2439
+ "",
2440
+ 'When to use: "what is throwing in production", triaging after a deploy, or finding the bug behind a user report. Pairs with list_alerts (which tells you an alert fired) and get_service_logs (which shows the raw output around it).',
2441
+ "",
2442
+ 'Grouping: an issue is one distinct problem. Its fingerprint is the exception class, the message with variable parts removed (so "user 41 not found" and "user 9002 not found" are ONE issue), and the topmost stack frame in the team\'s own code \u2014 not the framework\'s. `culprit` is that frame, and it is the line to open first.',
2443
+ "",
2444
+ "Counts are exact; occurrence samples are not. `occurrenceCount` is the true number of times it happened. A bounded number of full occurrences is retained per issue (see get_error_issue), and `droppedCount` is how many were counted but not stored because the service exceeded its hourly ingest quota.",
2445
+ "",
2446
+ 'Defaults to status=unresolved, because the list exists to answer "what is broken". Pass status=resolved or status=ignored for the others.',
2447
+ "",
2448
+ "Inputs (all optional): serviceId, status (unresolved|resolved|ignored), q (substring match on title/culprit), limit (default 50, max 200), offset, sort (last_seen|first_seen|count).",
2449
+ "",
2450
+ 'Returns: { issues: Array, total: number }. Each issue has id, publicId, serviceId, serviceName, type, title, culprit, level, status, occurrenceCount, droppedCount, affectedUsers (capped at 500 \u2014 500 means "500 or more"), firstSeenAt, lastSeenAt, firstSeenRelease, lastSeenRelease, environment, sourceTaskPublicId (a dev-box task already open for it).',
2451
+ "",
2452
+ "Example: list_error_issues({ serviceId: 48, sort: 'count' }) \u2192 { issues: [{ id: 12, type: 'TypeError', title: 'TypeError: cart.total is not a function', culprit: 'src/checkout.ts:12 in checkout', occurrenceCount: 4187, affectedUsers: 96, lastSeenRelease: 'a91f3c2', \u2026 }], total: 3 }."
2453
+ ].join("\n"),
2454
+ input: {
2455
+ serviceId: z13.number().int().positive().optional().describe("Only issues for this service."),
2456
+ status: z13.enum(["unresolved", "resolved", "ignored"]).optional().describe("Default unresolved."),
2457
+ q: z13.string().max(200).optional().describe("Substring match on title or culprit."),
2458
+ limit: z13.number().int().positive().max(200).optional().describe("Default 50, cap 200."),
2459
+ offset: z13.number().int().min(0).optional(),
2460
+ sort: z13.enum(["last_seen", "first_seen", "count"]).optional().describe("Default last_seen.")
2461
+ },
2462
+ handler: async (args2, ctx) => {
2463
+ const teamId = await ctx.resolveTeamId();
2464
+ const params = {};
2465
+ for (const key of ["serviceId", "status", "q", "limit", "offset", "sort"]) {
2466
+ const value = args2[key];
2467
+ if (value !== void 0) params[key] = String(value);
2468
+ }
2469
+ const response = await ctx.api.get(`/api/errors/${teamId}/issues`, params);
2470
+ const items = Array.isArray(response.issues) ? response.issues.map(shape) : [];
2471
+ const summary = items.length === 0 ? `No ${args2.status ?? "unresolved"} error issues${args2.serviceId ? " for that service" : ""}.` : `Returned ${items.length} of ${response.total} ${args2.status ?? "unresolved"} issue${response.total === 1 ? "" : "s"}.`;
2472
+ return respond({ summary, data: { issues: items, total: response.total } });
2473
+ }
2474
+ });
2475
+ defineTool({
2476
+ name: "get_error_issue",
2477
+ category: "errors",
2478
+ description: [
2479
+ "Fetch one error issue plus its most recent stored occurrences \u2014 the stack traces, request context and releases behind the aggregate.",
2480
+ "",
2481
+ "When to use: after list_error_issues has told you WHICH problem to look at, and you need the actual stack to reason about the cause.",
2482
+ "",
2483
+ "Occurrences are SAMPLES, not the full history: a bounded number is kept per issue while the count stays exact, and they age out after 30 days while the issue itself remains. An issue whose samples have expired returns an empty occurrences array \u2014 that is normal, not an error.",
2484
+ "",
2485
+ "Each occurrence has stack (raw, as the runtime printed it), context (tenant-supplied JSON, with sensitive keys already redacted), requestId, release, environment and createdAt.",
2486
+ "",
2487
+ "Inputs: issueId (required), occurrences (how many samples, default 5, max 50).",
2488
+ "",
2489
+ "Returns: { issue: {...}, occurrences: Array }.",
2490
+ "",
2491
+ "Example: get_error_issue({ issueId: 12 }) \u2192 { issue: { title: 'TypeError: cart.total is not a function', occurrenceCount: 4187, \u2026 }, occurrences: [{ stack: ' at checkout (/app/src/checkout.ts:12:9)\u2026', requestId: 'req_9f3c', release: 'a91f3c2', createdAt: '\u2026' }] }."
2492
+ ].join("\n"),
2493
+ input: {
2494
+ issueId: z13.number().int().positive().describe("Numeric issue id from list_error_issues."),
2495
+ occurrences: z13.number().int().positive().max(50).optional().describe("How many samples to include. Default 5.")
2496
+ },
2497
+ handler: async (args2, ctx) => {
2498
+ const teamId = await ctx.resolveTeamId();
2499
+ const [issueRes, occRes] = await Promise.all([
2500
+ ctx.api.get(`/api/errors/${teamId}/issues/${args2.issueId}`),
2501
+ ctx.api.get(
2502
+ `/api/errors/${teamId}/issues/${args2.issueId}/occurrences`,
2503
+ { limit: String(args2.occurrences ?? 5) }
2504
+ )
2505
+ ]);
2506
+ const occurrences = Array.isArray(occRes.occurrences) ? occRes.occurrences.map(shape) : [];
2507
+ const issue = shape(issueRes.issue);
2508
+ return respond({
2509
+ summary: `${String(issue["title"] ?? "Issue")} \u2014 ${String(issue["occurrenceCount"] ?? 0)} occurrence(s), ${occurrences.length} sample(s) available.`,
2510
+ data: { issue, occurrences }
2511
+ });
2512
+ }
2513
+ });
2514
+ defineTool({
2515
+ name: "update_error_issue",
2516
+ category: "errors",
2517
+ description: [
2518
+ "Resolve, ignore or reopen an error issue.",
2519
+ "",
2520
+ "When to use: after fixing a bug (resolve, so a recurrence is reported as a regression), or to silence noise you have decided to live with (ignore).",
2521
+ "",
2522
+ "`resolved` records the release it was resolved in. If the issue occurs again afterwards it reopens itself and fires an `error.issue_regressed` alert \u2014 which is the point of resolving rather than ignoring, and the reason not to resolve something you have not actually fixed.",
2523
+ "",
2524
+ "`ignored` keeps counting occurrences and stops telling anyone. An ignored issue never reopens itself, so this is the right choice for noise you have decided to live with, and the wrong choice for something you intend to fix.",
2525
+ "",
2526
+ "`unresolved` reopens it.",
2527
+ "",
2528
+ "Inputs: issueId (required), status (required).",
2529
+ "",
2530
+ "Returns: { issue: {...} } with the updated row.",
2531
+ "",
2532
+ "Example: update_error_issue({ issueId: 12, status: 'resolved' }) \u2192 { issue: { status: 'resolved', resolvedInRelease: 'a91f3c2', \u2026 } }."
2533
+ ].join("\n"),
2534
+ input: {
2535
+ issueId: z13.number().int().positive(),
2536
+ status: z13.enum(["unresolved", "resolved", "ignored"])
2537
+ },
2538
+ handler: async (args2, ctx) => {
2539
+ const teamId = await ctx.resolveTeamId();
2540
+ const response = await ctx.api.patch(
2541
+ `/api/errors/${teamId}/issues/${args2.issueId}`,
2542
+ { status: args2.status }
2543
+ );
2544
+ return respond({
2545
+ summary: `Issue ${args2.issueId} is now ${args2.status}.`,
2546
+ data: { issue: shape(response.issue) }
2547
+ });
2548
+ }
2549
+ });
2550
+ defineTool({
2551
+ name: "fix_error_in_dev_box",
2552
+ category: "errors",
2553
+ description: [
2554
+ "Turn an error issue into a coding-agent task in the project's dev box, with the repository already checked out.",
2555
+ "",
2556
+ "When to use: the issue is real, it is in the team's own code, and somebody is going to have to open the repository anyway. This writes the whole briefing so nobody has to reconstruct it from the dashboard.",
2557
+ "",
2558
+ "This is the thing a third-party error tracker structurally cannot do. The task carries the exception, the stack with the team's OWN frames marked (`>>`), the release it happened on, how often and to how many users, and a real request context \u2014 plus hard boundaries: work on a branch, do not deploy, do not delete tests, and stop and say so if the cause turns out not to be in this repository.",
2559
+ "",
2560
+ "It CREATES the task; it does not start the agent. Running spends the team's own agent tokens and is a separate decision made in the dev box UI.",
2561
+ "",
2562
+ "Refused with 422 when the cause cannot be attributed to the repository \u2014 every stack frame inside a dependency, no usable stack at all, a browser extension, or a connection failure with no in-app frame. That gate is deliberate: an agent told to fix something it cannot reach does not refuse, it produces a confident and useless diff.",
2563
+ "",
2564
+ "Pressing this twice for one issue returns the existing task (alreadyExisted=true) rather than queueing a duplicate run.",
2565
+ "",
2566
+ "Inputs: issueId (required), serviceId (optional \u2014 pin to a specific dev box; omitted, the project's box is used).",
2567
+ "",
2568
+ "Returns: { task, alreadyExisted, box: { serviceId, name, asleep }, automodeEnabled }.",
2569
+ "",
2570
+ "Example: fix_error_in_dev_box({ issueId: 12 }) \u2192 { task: { title: 'Fix error: TypeError in src/checkout.ts:12' }, alreadyExisted: false, box: { name: 'shop-dev', asleep: true }, automodeEnabled: false }."
2571
+ ].join("\n"),
2572
+ input: {
2573
+ issueId: z13.number().int().positive(),
2574
+ serviceId: z13.number().int().positive().optional().describe("Dev box to pin the task to. Omitted, the project's box is used.")
2575
+ },
2576
+ handler: async (args2, ctx) => {
2577
+ const teamId = await ctx.resolveTeamId();
2578
+ const body = { issueId: args2.issueId };
2579
+ if (args2.serviceId !== void 0) body["serviceId"] = args2.serviceId;
2580
+ const response = await ctx.api.post(
2581
+ `/api/dev-env-tasks/${teamId}/from-issue`,
2582
+ body
2583
+ );
2584
+ const verb = response.alreadyExisted ? "Already queued" : "Queued";
2585
+ const note = response.box.asleep ? " That box is asleep \u2014 resume it before running the task." : "";
2586
+ return respond({
2587
+ summary: `${verb} in dev box "${response.box.name}": ${response.task.title ?? "task"}.${note}`,
2588
+ data: shape(response)
2589
+ });
2590
+ }
2591
+ });
2592
+ defineTool({
2593
+ name: "list_ingest_keys",
2594
+ category: "errors",
2595
+ description: [
2596
+ "List a service's error-ingest keys. Only prefixes and last-used timestamps \u2014 the key itself is stored as a hash and is never readable again.",
2597
+ "",
2598
+ "When to use: checking whether a service is wired up for error reporting at all, or whether an old key is still in use before revoking it (`lastUsedAt` is stamped lazily, at most once a minute).",
2599
+ "",
2600
+ "Inputs: serviceId (required).",
2601
+ "",
2602
+ "Returns: { keys: Array } with id, publicId, name, prefix, lastUsedAt, createdAt.",
2603
+ "",
2604
+ "Example: list_ingest_keys({ serviceId: 48 }) \u2192 { keys: [{ id: 3, name: 'default', prefix: 'ing_9f3c1ab2', lastUsedAt: '2026-08-21T09:14:00Z' }] }."
2605
+ ].join("\n"),
2606
+ input: { serviceId: z13.number().int().positive() },
2607
+ handler: async (args2, ctx) => {
2608
+ const teamId = await ctx.resolveTeamId();
2609
+ const response = await ctx.api.get(
2610
+ `/api/services/${teamId}/${args2.serviceId}/ingest-keys`
2611
+ );
2612
+ const keys = Array.isArray(response.keys) ? response.keys.map(shape) : [];
2613
+ return respond({
2614
+ summary: keys.length === 0 ? "No ingest keys on this service \u2014 it cannot report errors yet." : `${keys.length} ingest key${keys.length === 1 ? "" : "s"} on this service.`,
2615
+ data: { keys }
2616
+ });
2617
+ }
2618
+ });
2619
+ defineTool({
2620
+ name: "create_ingest_key",
2621
+ category: "errors",
2622
+ description: [
2623
+ "Mint a write-only error-ingest key for a service, so its application can report exceptions.",
2624
+ "",
2625
+ "When to use: setting a service up for error tracking for the first time, or rotating a key that has leaked or is being retired.",
2626
+ "",
2627
+ "The response is the ONLY place the plaintext key ever exists \u2014 it is stored as a SHA-256 hash and cannot be shown again. Put it wherever the application reads it from (an env var, usually) before discarding the response.",
2628
+ "",
2629
+ "The key is deliberately low-privilege: it can create error events for this ONE service and can do nothing else \u2014 it cannot read the issues it created, list services, or touch anything on the team. That is why shipping it inside the application, including a browser bundle where it is world-readable, is expected rather than a mistake.",
2630
+ "",
2631
+ 'Reporting is a plain HTTP POST, no SDK required: POST {baseUrl}/api/ingest/errors/{key} with {"events":[{"type","value","stack","level","context"}]}, up to 100 events per request.',
2632
+ "",
2633
+ 'Inputs: serviceId (required), name (optional label, default "default"). Max 5 keys per service \u2014 the extras exist so a key can be rotated without downtime (create the second, ship it, delete the first).',
2634
+ "",
2635
+ "Returns: { key: { id, publicId, name, prefix, key } } where `key` is the plaintext.",
2636
+ "",
2637
+ "Example: create_ingest_key({ serviceId: 48 }) \u2192 { id: 4, name: 'default', prefix: 'ing_1b7d40ae', key: 'ing_1b7d40ae\u2026' }."
2638
+ ].join("\n"),
2639
+ input: {
2640
+ serviceId: z13.number().int().positive(),
2641
+ name: z13.string().min(1).max(100).optional().describe('Label. Default "default".')
2642
+ },
2643
+ handler: async (args2, ctx) => {
2644
+ const teamId = await ctx.resolveTeamId();
2645
+ const response = await ctx.api.post(
2646
+ `/api/services/${teamId}/${args2.serviceId}/ingest-keys`,
2647
+ { name: args2.name ?? "default" }
2648
+ );
2649
+ return respond({
2650
+ summary: "Ingest key created. The plaintext key is in the payload and is not retrievable again \u2014 store it now.",
2651
+ data: shape(response.key)
2652
+ });
2653
+ }
2654
+ });
2655
+ defineTool({
2656
+ name: "delete_ingest_key",
2657
+ category: "errors",
2658
+ description: [
2659
+ "Revoke an error-ingest key. It stops working immediately, including on API replicas that had it cached.",
2660
+ "",
2661
+ "When to use: finishing a key rotation, or containing a key that leaked somewhere it should not have.",
2662
+ "",
2663
+ "Anything still posting with it starts getting 401s, so revoke the OLD key after the new one is deployed, not before.",
2664
+ "",
2665
+ "Inputs: serviceId, keyId (both required).",
2666
+ "",
2667
+ "Returns: { success: true }.",
2668
+ "",
2669
+ "Example: delete_ingest_key({ serviceId: 48, keyId: 3 }) \u2192 { success: true }."
2670
+ ].join("\n"),
2671
+ input: {
2672
+ serviceId: z13.number().int().positive(),
2673
+ keyId: z13.number().int().positive()
2674
+ },
2675
+ handler: async (args2, ctx) => {
2676
+ const teamId = await ctx.resolveTeamId();
2677
+ await ctx.api.delete(`/api/services/${teamId}/${args2.serviceId}/ingest-keys/${args2.keyId}`);
2678
+ return respond({ summary: `Ingest key ${args2.keyId} revoked.` });
2679
+ }
2680
+ });
2681
+
2153
2682
  // src/tools/github.ts
2154
2683
  defineTool({
2155
2684
  name: "sync_github_repos",
@@ -2192,6 +2721,61 @@ defineTool({
2192
2721
  }
2193
2722
  });
2194
2723
 
2724
+ // src/tools/issue-reports.ts
2725
+ import { z as z14 } from "zod";
2726
+ defineTool({
2727
+ name: "report_issue",
2728
+ category: "support",
2729
+ description: [
2730
+ "File a bug or platform fault with the HostStack team. Use this when something on the PLATFORM is broken in a way you cannot fix from where you stand \u2014 a deploy that fails identically no matter what you change, a dev box that will not start, an API that returns the wrong thing, an error message that does not match what actually happened.",
2731
+ "",
2732
+ 'When to use: you have already looked. This is not a substitute for reading logs (get_deploy_logs, get_service_logs) or for diagnose_deploy \u2014 it is what you do once you have a specific fault and no way to act on it. A report that says "the deploy failed" is worth less than no report; one that says "every deploy since 09:03 fails at container create with 404 No such image, but the image pulls fine by hand" is what gets fixed.',
2733
+ "",
2734
+ "When NOT to use: an application bug in the user's own code, a failing test, a misconfigured env var, or anything the user asked you to change. Those are your job, not a platform fault. Do not file the same issue twice \u2014 if you already reported it this session, say so instead.",
2735
+ "",
2736
+ "Diagnostic context is attached SERVER-SIDE, so you do not need to paste it: passing `serviceId` attaches the service and its most recent deploy, and the last 40 lines of that deploy log go with it. Pass `deployId` to pin a specific deploy instead of the newest. Describe what you observed and what you expected \u2014 the evidence is collected for you.",
2737
+ "",
2738
+ "Inputs: title (required, one line), description (required \u2014 what happened, what you expected, what you already ruled out), severity (low|normal|high|urgent, default normal), serviceId (optional), deployId (optional), databaseId (optional).",
2739
+ "",
2740
+ "Rate-limited to 5 reports per minute per team. Filing one notifies the HostStack team and opens a ticket the user can see and reply to.",
2741
+ "",
2742
+ "Returns: { ticket: { id, publicId } } \u2014 quote the publicId to the user so they can follow it up.",
2743
+ "",
2744
+ "Example: report_issue({ title: 'Dev box deploys fail with 404 No such image', description: 'Every deploy of svc 172 since 2026-08-22 09:03 fails at container create with `404 No such image: \u2026/dev-env:latest`. The deploy log reports \"Image ready in 1s\" for a 5.5 GB image, so the pull is not happening. The image pulls fine by hand from the same registry.', severity: 'high', serviceId: 172 }) \u2192 { ticket: { publicId: 'tkt_\u2026' } }"
2745
+ ].join("\n"),
2746
+ input: {
2747
+ title: z14.string().min(1).max(300).describe("One-line summary of the fault."),
2748
+ description: z14.string().min(1).max(1e4).describe("What happened, what you expected instead, and what you already ruled out."),
2749
+ severity: z14.enum(["low", "normal", "high", "urgent"]).optional().describe(
2750
+ 'Default normal. Use high/urgent only when something is DOWN or losing data \u2014 not for "this is annoying".'
2751
+ ),
2752
+ serviceId: z14.number().int().positive().optional().describe(
2753
+ "The affected service. Attaches the service, its latest deploy, and that deploy log tail automatically."
2754
+ ),
2755
+ deployId: z14.number().int().positive().optional().describe("Pin a specific deploy instead of the service\u2019s most recent one."),
2756
+ databaseId: z14.number().int().positive().optional().describe("The affected database.")
2757
+ },
2758
+ handler: async (args2, ctx) => {
2759
+ const teamId = await ctx.resolveTeamId();
2760
+ const body = {
2761
+ title: args2.title,
2762
+ description: args2.description,
2763
+ severity: args2.severity ?? "normal"
2764
+ };
2765
+ if (args2.serviceId !== void 0) body["serviceId"] = args2.serviceId;
2766
+ if (args2.deployId !== void 0) body["deployId"] = args2.deployId;
2767
+ if (args2.databaseId !== void 0) body["databaseId"] = args2.databaseId;
2768
+ const response = await ctx.api.post(
2769
+ `/api/issue-reports/${teamId}`,
2770
+ body
2771
+ );
2772
+ return respond({
2773
+ summary: `Issue reported as ${response.ticket.publicId}. Tell the user the reference so they can follow it up \u2014 and do not file this one again.`,
2774
+ data: shape(response.ticket)
2775
+ });
2776
+ }
2777
+ });
2778
+
2195
2779
  // src/tools/meta.ts
2196
2780
  var DEV_ENV_TOOL_NAMES = [
2197
2781
  "create_dev_environment",
@@ -2257,7 +2841,7 @@ defineTool({
2257
2841
  });
2258
2842
 
2259
2843
  // src/tools/notifications.ts
2260
- import { z as z12 } from "zod";
2844
+ import { z as z15 } from "zod";
2261
2845
  var NOTIFICATION_EVENTS = [
2262
2846
  "deploy.started",
2263
2847
  "deploy.succeeded",
@@ -2271,9 +2855,25 @@ var NOTIFICATION_EVENTS = [
2271
2855
  "service.auto_suspended",
2272
2856
  "service.acme_cert_failed",
2273
2857
  "service.resource_alert",
2858
+ "service.uptime_down",
2859
+ "service.uptime_recovered",
2860
+ "error.issue_new",
2861
+ "error.issue_regressed",
2274
2862
  "git.auth_failed",
2275
2863
  "cron.execution_failed",
2276
- "workflow.failed"
2864
+ "workflow.failed",
2865
+ "devenv.agent.needs_input",
2866
+ "devenv.agent.finished",
2867
+ "devenv.task.created",
2868
+ "devenv.task.needs_input",
2869
+ "devenv.task.finished",
2870
+ "database.backup_failed",
2871
+ "database.restore_failed",
2872
+ "machine.offline",
2873
+ "machine.online",
2874
+ "billing.invoice",
2875
+ "billing.payment_failed",
2876
+ "billing.spend_limit"
2277
2877
  ];
2278
2878
  defineTool({
2279
2879
  name: "list_notification_channels",
@@ -2312,17 +2912,21 @@ defineTool({
2312
2912
  " - webhook_url: Slack/Discord webhook URL OR email address.",
2313
2913
  " - events: list of event names to subscribe to. Pass an empty list to create a channel that fires for nothing (manual subscribe later with update_notification_channel).",
2314
2914
  "",
2315
- "Valid events: deploy.started, deploy.succeeded, deploy.failed, deploy.failed_consecutive, service.created, service.deleted, service.suspended, service.resumed, service.restart_failed, service.auto_suspended, service.acme_cert_failed, service.resource_alert, git.auth_failed, cron.execution_failed, workflow.failed.",
2915
+ // Rendered from the enum instead of retyped: the hand-written copy of
2916
+ // this sentence went stale the same way the enum did, and a description
2917
+ // that disagrees with the schema teaches the model to send values the
2918
+ // tool then rejects.
2919
+ `Valid events: ${NOTIFICATION_EVENTS.join(", ")}.`,
2316
2920
  "",
2317
2921
  "Returns: { channel: Channel }.",
2318
2922
  "",
2319
2923
  "Example: create_notification_channel({ type: 'slack', name: 'eng-alerts', webhook_url: 'https://hooks.slack.com/\u2026', events: ['deploy.failed', 'git.auth_failed', 'service.restart_failed'] })"
2320
2924
  ].join("\n"),
2321
2925
  input: {
2322
- type: z12.enum(["slack", "discord", "email"]).describe("Channel type."),
2323
- name: z12.string().min(1).max(128).describe("Human-readable label."),
2324
- webhook_url: z12.string().max(500).describe("Slack/Discord webhook URL or email address (when type=email)."),
2325
- events: z12.array(z12.enum(NOTIFICATION_EVENTS)).describe(
2926
+ type: z15.enum(["slack", "discord", "email"]).describe("Channel type."),
2927
+ name: z15.string().min(1).max(128).describe("Human-readable label."),
2928
+ webhook_url: z15.string().max(500).describe("Slack/Discord webhook URL or email address (when type=email)."),
2929
+ events: z15.array(z15.enum(NOTIFICATION_EVENTS)).describe(
2326
2930
  "List of events the channel subscribes to. Empty list = subscribe to nothing."
2327
2931
  )
2328
2932
  },
@@ -2360,10 +2964,10 @@ defineTool({
2360
2964
  "Example: update_notification_channel({ channel_id: 3, events: ['deploy.failed', 'service.restart_failed', 'git.auth_failed'] })"
2361
2965
  ].join("\n"),
2362
2966
  input: {
2363
- channel_id: z12.number().int().positive().describe("Numeric channel id from list_notification_channels."),
2364
- name: z12.string().min(1).max(128).optional().describe("New label."),
2365
- active: z12.boolean().optional().describe("false silences without deleting."),
2366
- events: z12.array(z12.enum(NOTIFICATION_EVENTS)).optional().describe("Replaces the full subscription list.")
2967
+ channel_id: z15.number().int().positive().describe("Numeric channel id from list_notification_channels."),
2968
+ name: z15.string().min(1).max(128).optional().describe("New label."),
2969
+ active: z15.boolean().optional().describe("false silences without deleting."),
2970
+ events: z15.array(z15.enum(NOTIFICATION_EVENTS)).optional().describe("Replaces the full subscription list.")
2367
2971
  },
2368
2972
  handler: async (args2, ctx) => {
2369
2973
  const teamId = await ctx.resolveTeamId();
@@ -2402,7 +3006,7 @@ defineTool({
2402
3006
  "Example: delete_notification_channel({ channel_id: 3 }) \u2192 { ok: true }"
2403
3007
  ].join("\n"),
2404
3008
  input: {
2405
- channel_id: z12.number().int().positive().describe("Numeric channel id.")
3009
+ channel_id: z15.number().int().positive().describe("Numeric channel id.")
2406
3010
  },
2407
3011
  handler: async (args2, ctx) => {
2408
3012
  const teamId = await ctx.resolveTeamId();
@@ -2429,7 +3033,7 @@ defineTool({
2429
3033
  "Example: test_notification_channel({ channel_id: 3 }) \u2192 { success: true }"
2430
3034
  ].join("\n"),
2431
3035
  input: {
2432
- channel_id: z12.number().int().positive().describe("Numeric channel id.")
3036
+ channel_id: z15.number().int().positive().describe("Numeric channel id.")
2433
3037
  },
2434
3038
  handler: async (args2, ctx) => {
2435
3039
  const teamId = await ctx.resolveTeamId();
@@ -2444,7 +3048,7 @@ defineTool({
2444
3048
  });
2445
3049
 
2446
3050
  // src/tools/projects.ts
2447
- import { z as z13 } from "zod";
3051
+ import { z as z16 } from "zod";
2448
3052
  var AVAILABLE_REGION_IDS = ["eu-central-1"];
2449
3053
  defineTool({
2450
3054
  name: "list_projects",
@@ -2485,9 +3089,9 @@ defineTool({
2485
3089
  'Example: create_project({ name: "billing-api", description: "Stripe webhooks", region: "eu-central-1" }) \u2192 { project: { id: 12, publicId: "prj_\u2026", \u2026 } }'
2486
3090
  ].join("\n"),
2487
3091
  input: {
2488
- name: z13.string().min(1).max(60).describe("Project name (1\u201360 chars)."),
2489
- description: z13.string().max(500).optional().describe("Short description (\u2264500 chars)."),
2490
- region: z13.enum(AVAILABLE_REGION_IDS).optional().describe("Region: eu-central-1 (Falkenstein) \u2014 currently the only available region.")
3092
+ name: z16.string().min(1).max(60).describe("Project name (1\u201360 chars)."),
3093
+ description: z16.string().max(500).optional().describe("Short description (\u2264500 chars)."),
3094
+ region: z16.enum(AVAILABLE_REGION_IDS).optional().describe("Region: eu-central-1 (Falkenstein) \u2014 currently the only available region.")
2491
3095
  },
2492
3096
  handler: async (args2, ctx) => {
2493
3097
  const teamId = await ctx.resolveTeamId();
@@ -2520,9 +3124,9 @@ defineTool({
2520
3124
  'Example: update_project({ project_id: "prj_abc", name: "billing-prod" }) \u2192 { project: { name: "billing-prod", \u2026 } }'
2521
3125
  ].join("\n"),
2522
3126
  input: {
2523
- project_id: z13.string().describe("Project publicId."),
2524
- name: z13.string().min(1).max(60).optional().describe("New name (1\u201360 chars)."),
2525
- description: z13.string().max(500).optional().describe("New description (\u2264500 chars).")
3127
+ project_id: z16.string().describe("Project publicId."),
3128
+ name: z16.string().min(1).max(60).optional().describe("New name (1\u201360 chars)."),
3129
+ description: z16.string().max(500).optional().describe("New description (\u2264500 chars).")
2526
3130
  },
2527
3131
  handler: async (args2, ctx) => {
2528
3132
  if (args2.name === void 0 && args2.description === void 0) {
@@ -2556,7 +3160,7 @@ defineTool({
2556
3160
  'Example: get_project({ project_id: "prj_abc" }) \u2192 { project: { id: 12, name: "billing", \u2026 } }'
2557
3161
  ].join("\n"),
2558
3162
  input: {
2559
- project_id: z13.string().describe("Project publicId (e.g. prj_abc123).")
3163
+ project_id: z16.string().describe("Project publicId (e.g. prj_abc123).")
2560
3164
  },
2561
3165
  handler: async (args2, ctx) => {
2562
3166
  const teamId = await ctx.resolveTeamId();
@@ -2567,8 +3171,156 @@ defineTool({
2567
3171
  }
2568
3172
  });
2569
3173
 
3174
+ // src/tools/dev-tasks.ts
3175
+ import { z as z17 } from "zod";
3176
+ var NOT_A_RUN = "Filing a task does NOT start an agent. It writes a prompt into the box's backlog for someone to run; a suspended box keeps it until it wakes.";
3177
+ defineTool({
3178
+ name: "list_dev_tasks",
3179
+ category: "dev-tasks",
3180
+ description: [
3181
+ "List a project's agent task backlog - the same list the dashboard's Development \u2192 Tasks surface shows.",
3182
+ "",
3183
+ "When to use: to see what is already queued for a box before filing something (the backlog is how you avoid two agents being pointed at one job), or to find the task id for update_dev_task.",
3184
+ "",
3185
+ "Inputs:",
3186
+ ' - project_id: numeric project id (from list_projects) or its "prj_\u2026" publicId.',
3187
+ "",
3188
+ "Returns: { summary, data: { items: DevTask[] } } - each task with publicId, title, status, serviceId (the box, or null for a loose idea), createdAt.",
3189
+ "",
3190
+ "`status` spans two worlds: `idea`/`done` are what a person sets, while `queued`/`running`/`needs_input`/`failed`/`cancelled` describe an agent run and belong to the runner.",
3191
+ "",
3192
+ 'Example: list_dev_tasks({ project_id: 26 }) \u2192 { items: [{ publicId: "task_\u2026", status: "idea", title: "Fix the footprint join" }] }'
3193
+ ].join("\n"),
3194
+ input: {
3195
+ project_id: z17.union([z17.number().int().positive(), z17.string()]).describe('Project \u2014 publicId ("prj_\u2026") or numeric id.')
3196
+ },
3197
+ handler: async (args2, ctx) => {
3198
+ const teamId = await ctx.resolveTeamId();
3199
+ const response = await ctx.hoststack.devTasks.list(teamId, args2.project_id);
3200
+ const data = shapeList(response, "tasks", shape);
3201
+ const open = data.items.filter(
3202
+ (t) => t && typeof t === "object" && "status" in t && !["done", "cancelled"].includes(String(t.status))
3203
+ ).length;
3204
+ const summary = data.items.length === 0 ? "No tasks in this project." : `${data.items.length} task${data.items.length === 1 ? "" : "s"}, ${open} still open.${response.automodeEnabled ? "" : " Automode is off here, so a queued task waits for someone to start it."}`;
3205
+ return respond({ summary, data });
3206
+ }
3207
+ });
3208
+ defineTool({
3209
+ name: "get_dev_task",
3210
+ category: "dev-tasks",
3211
+ description: [
3212
+ "Get one task, including the full prompt body.",
3213
+ "",
3214
+ "When to use: to read what a task actually asks for before acting on it or marking it done - `list_dev_tasks` returns titles, not prompts.",
3215
+ "",
3216
+ "Inputs:",
3217
+ ' - task_id: "task_\u2026" publicId or numeric id.',
3218
+ "",
3219
+ "Returns: { summary, data: DevTask }.",
3220
+ "",
3221
+ 'Example: get_dev_task({ task_id: "task_hy1i2jdp\u2026" }) \u2192 { data: { title: "Footprint coverage", body: "The BBRUUID join returns 17 of 49 \u2026", status: "idea" } }'
3222
+ ].join("\n"),
3223
+ input: {
3224
+ task_id: z17.union([z17.number().int().positive(), z17.string()]).describe('Task \u2014 publicId ("task_\u2026") or numeric id.')
3225
+ },
3226
+ handler: async (args2, ctx) => {
3227
+ const teamId = await ctx.resolveTeamId();
3228
+ const response = await ctx.hoststack.devTasks.get(teamId, args2.task_id);
3229
+ const data = shape(response.task);
3230
+ return respond({ summary: `Task ${response.task.publicId}: ${response.task.title}`, data });
3231
+ }
3232
+ });
3233
+ defineTool({
3234
+ name: "create_dev_task",
3235
+ category: "dev-tasks",
3236
+ description: [
3237
+ "File a task in a project's backlog, optionally pinned to a specific dev box.",
3238
+ "",
3239
+ `When to use: to hand work to a dev box other than the one you are in - a fix that belongs in an upstream service, a follow-up in a different repo, anything the box you are in cannot do itself. ${NOT_A_RUN}`,
3240
+ "",
3241
+ "Inputs:",
3242
+ ' - project_id: numeric project id or "prj_\u2026" publicId.',
3243
+ " - title: one line, \u2264200 chars. This is what the backlog shows.",
3244
+ " - body: the prompt, markdown, \u226420 000 chars. Write it for someone who was not in this conversation: what is broken, where, how it was measured, and what to watch out for.",
3245
+ ' - service_id: the dev box to pin it to ("svc_\u2026" or numeric). Omit for a loose idea in the project backlog.',
3246
+ "",
3247
+ "Returns: { summary, data: DevTask } - `publicId` is the id to quote back to the user.",
3248
+ "",
3249
+ 'Example: create_dev_task({ project_id: 26, service_id: 51, title: "Footprint coverage", body: "The BBRUUID join returns 17 of 49 \u2026" })'
3250
+ ].join("\n"),
3251
+ input: {
3252
+ project_id: z17.union([z17.number().int().positive(), z17.string()]).describe('Project \u2014 publicId ("prj_\u2026") or numeric id.'),
3253
+ title: z17.string().min(1).max(200).describe("One-line title, \u2264200 chars."),
3254
+ body: z17.string().max(2e4).optional().describe("The prompt handed to the agent. Markdown, \u226420 000 chars."),
3255
+ service_id: z17.union([z17.number().int().positive(), z17.string()]).optional().describe(
3256
+ 'Dev box to pin it to \u2014 publicId ("svc_\u2026") or numeric id. Omit for a loose idea.'
3257
+ )
3258
+ },
3259
+ handler: async (args2, ctx) => {
3260
+ const teamId = await ctx.resolveTeamId();
3261
+ const serviceId = args2.service_id === void 0 ? void 0 : await ctx.hoststack.resolveId(args2.service_id, { kind: "service", teamId });
3262
+ const projectId = await ctx.hoststack.resolveId(args2.project_id, {
3263
+ kind: "project",
3264
+ teamId
3265
+ });
3266
+ const response = await ctx.hoststack.devTasks.create(teamId, {
3267
+ projectId,
3268
+ title: args2.title,
3269
+ ...args2.body ? { body: args2.body } : {},
3270
+ ...serviceId ? { serviceId } : {}
3271
+ });
3272
+ const data = shape(response.task);
3273
+ return respond({
3274
+ summary: `Filed ${response.task.publicId}: ${response.task.title}. ${NOT_A_RUN}`,
3275
+ data
3276
+ });
3277
+ }
3278
+ });
3279
+ defineTool({
3280
+ name: "update_dev_task",
3281
+ category: "dev-tasks",
3282
+ description: [
3283
+ "Edit a task, or move it between the two statuses a PERSON may set.",
3284
+ "",
3285
+ "When to use: to mark a task `done` once the work has landed (by hand, or in another branch - most work does not finish inside the task runner), to reopen it as an `idea`, to correct a title or prompt, or to pin a loose idea to a box.",
3286
+ "",
3287
+ "Inputs:",
3288
+ ' - task_id: "task_\u2026" publicId or numeric id.',
3289
+ ' - status: "done" or "idea". Only these two; `queued`/`running`/`failed`/`cancelled` belong to the runner and are rejected.',
3290
+ " - title / body / service_id: optional edits.",
3291
+ "",
3292
+ "Only mark a task done when it is actually resolved - the backlog is what someone reads to decide what still needs doing.",
3293
+ "",
3294
+ "Returns: { summary, data: DevTask }.",
3295
+ "",
3296
+ 'Example: update_dev_task({ task_id: "task_hy1i2jdp\u2026", status: "done" }) \u2192 { data: { status: "done" } }'
3297
+ ].join("\n"),
3298
+ input: {
3299
+ task_id: z17.union([z17.number().int().positive(), z17.string()]).describe('Task \u2014 publicId ("task_\u2026") or numeric id.'),
3300
+ status: z17.enum(["idea", "done"]).optional().describe("The only two a person may set. The runner owns the rest of the lifecycle."),
3301
+ title: z17.string().min(1).max(200).optional().describe("New title."),
3302
+ body: z17.string().max(2e4).optional().describe("New prompt body."),
3303
+ service_id: z17.union([z17.number().int().positive(), z17.string()]).nullable().optional().describe("Pin to a dev box, or null to unpin it back to a loose idea.")
3304
+ },
3305
+ handler: async (args2, ctx) => {
3306
+ const teamId = await ctx.resolveTeamId();
3307
+ const serviceId = args2.service_id === void 0 || args2.service_id === null ? args2.service_id : await ctx.hoststack.resolveId(args2.service_id, { kind: "service", teamId });
3308
+ const response = await ctx.hoststack.devTasks.update(teamId, args2.task_id, {
3309
+ ...args2.status ? { status: args2.status } : {},
3310
+ ...args2.title ? { title: args2.title } : {},
3311
+ ...args2.body !== void 0 ? { body: args2.body } : {},
3312
+ ...serviceId !== void 0 ? { serviceId } : {}
3313
+ });
3314
+ const data = shape(response.task);
3315
+ return respond({
3316
+ summary: `${response.task.publicId} is now ${response.task.status}: ${response.task.title}`,
3317
+ data
3318
+ });
3319
+ }
3320
+ });
3321
+
2570
3322
  // src/tools/resource-links.ts
2571
- import { z as z14 } from "zod";
3323
+ import { z as z18 } from "zod";
2572
3324
  var RESOURCE_LINK_TYPES = [
2573
3325
  "database",
2574
3326
  "object_storage",
@@ -2629,7 +3381,7 @@ defineTool({
2629
3381
  'Example: list_service_resources({ service_id: "svc_abc" }) \u2192 { items: [{ id: 7, resourceType: "database", resourceId: 42, alias: "APP_DB" }] }'
2630
3382
  ].join("\n"),
2631
3383
  input: {
2632
- service_id: z14.union([z14.number().int().positive(), z14.string()]).describe('Service \u2014 publicId ("svc_\u2026") or numeric id.')
3384
+ service_id: z18.union([z18.number().int().positive(), z18.string()]).describe('Service \u2014 publicId ("svc_\u2026") or numeric id.')
2633
3385
  },
2634
3386
  handler: async (args2, ctx) => {
2635
3387
  const teamId = await ctx.resolveTeamId();
@@ -2666,10 +3418,10 @@ defineTool({
2666
3418
  'Example: link_resource_to_service({ service_id: "svc_abc", resource_type: "database", resource_id: 42, alias: "APP_DB" }) \u2192 { link: { id: 7, alias: "APP_DB" } }'
2667
3419
  ].join("\n"),
2668
3420
  input: {
2669
- service_id: z14.union([z14.number().int().positive(), z14.string()]).describe('Consuming service \u2014 publicId ("svc_\u2026") or numeric id.'),
2670
- resource_type: z14.enum(RESOURCE_LINK_TYPES).describe("Kind of resource being linked."),
2671
- resource_id: z14.number().int().positive().describe("NUMERIC id of the resource (e.g. database.id) \u2014 not the publicId."),
2672
- alias: z14.string().min(1).max(48).regex(
3421
+ service_id: z18.union([z18.number().int().positive(), z18.string()]).describe('Consuming service \u2014 publicId ("svc_\u2026") or numeric id.'),
3422
+ resource_type: z18.enum(RESOURCE_LINK_TYPES).describe("Kind of resource being linked."),
3423
+ resource_id: z18.number().int().positive().describe("NUMERIC id of the resource (e.g. database.id) \u2014 not the publicId."),
3424
+ alias: z18.string().min(1).max(48).regex(
2673
3425
  /^[A-Z][A-Z0-9_]*$/,
2674
3426
  "Alias must be uppercase letters, digits and underscores, starting with a letter."
2675
3427
  ).describe('Uppercase env-var prefix, e.g. "APP_DB". Unique within the service.')
@@ -2705,8 +3457,8 @@ defineTool({
2705
3457
  'Example: unlink_resource_from_service({ service_id: "svc_abc", link_id: 7 }) \u2192 { ok: true }'
2706
3458
  ].join("\n"),
2707
3459
  input: {
2708
- service_id: z14.union([z14.number().int().positive(), z14.string()]).describe('Service \u2014 publicId ("svc_\u2026") or numeric id.'),
2709
- link_id: z14.number().int().positive().describe("Numeric linkId from list_service_resources (the link's own `id`).")
3460
+ service_id: z18.union([z18.number().int().positive(), z18.string()]).describe('Service \u2014 publicId ("svc_\u2026") or numeric id.'),
3461
+ link_id: z18.number().int().positive().describe("Numeric linkId from list_service_resources (the link's own `id`).")
2710
3462
  },
2711
3463
  handler: async (args2, ctx) => {
2712
3464
  const teamId = await ctx.resolveTeamId();
@@ -2718,7 +3470,7 @@ defineTool({
2718
3470
  });
2719
3471
 
2720
3472
  // src/tools/services.ts
2721
- import { z as z15 } from "zod";
3473
+ import { z as z19 } from "zod";
2722
3474
 
2723
3475
  // src/lib/app-templates.ts
2724
3476
  var MCP_APP_TEMPLATES = [
@@ -2868,6 +3620,46 @@ var MCP_APP_TEMPLATES = [
2868
3620
  installCommand: "npm install",
2869
3621
  startCommand: "node worker.js"
2870
3622
  },
3623
+ {
3624
+ id: "uptime-kuma",
3625
+ name: "Uptime Kuma",
3626
+ description: "Self-hosted uptime monitoring and status pages, with its own SQLite store",
3627
+ type: "web_service",
3628
+ dockerImage: "louislam/uptime-kuma:1",
3629
+ port: 3e3
3630
+ },
3631
+ {
3632
+ id: "vaultwarden",
3633
+ name: "Vaultwarden",
3634
+ description: "Self-hosted Bitwarden-compatible password manager (unofficial server)",
3635
+ type: "web_service",
3636
+ dockerImage: "vaultwarden/server:1.32.7-alpine",
3637
+ port: 3e3
3638
+ },
3639
+ {
3640
+ id: "n8n",
3641
+ name: "n8n",
3642
+ description: "Self-hosted workflow automation \u2014 visual editor, 400+ integrations",
3643
+ type: "web_service",
3644
+ dockerImage: "n8nio/n8n:1",
3645
+ port: 5678
3646
+ },
3647
+ {
3648
+ id: "wordpress",
3649
+ name: "WordPress",
3650
+ description: "PHP 8.3 + Apache, with a managed MySQL database (billed separately)",
3651
+ type: "web_service",
3652
+ dockerImage: "wordpress:php8.3-apache",
3653
+ port: 80
3654
+ },
3655
+ {
3656
+ id: "ghost",
3657
+ name: "Ghost",
3658
+ description: "Blogs and newsletters, with a managed MySQL database (billed separately)",
3659
+ type: "web_service",
3660
+ dockerImage: "ghost:5-alpine",
3661
+ port: 2368
3662
+ },
2871
3663
  {
2872
3664
  id: "cron-cleanup",
2873
3665
  name: "Cleanup Cron",
@@ -2935,11 +3727,11 @@ defineTool({
2935
3727
  'Example: list_services({ status: "failed" }) \u2192 only services that need attention.'
2936
3728
  ].join("\n"),
2937
3729
  input: {
2938
- project_id: z15.union([z15.number().int().positive(), z15.string()]).optional().describe("Project filter \u2014 numeric id or publicId."),
2939
- environment_id: z15.union([z15.number().int().positive(), z15.string()]).optional().describe("Environment filter \u2014 numeric id or publicId."),
2940
- status: z15.enum(["active", "deploying", "suspended", "failed", "not_deployed"]).optional().describe("Filter by current runtime status."),
2941
- type: z15.enum(["web_service", "private_service", "worker", "cron_job", "static_site"]).optional().describe("Filter by service type."),
2942
- dev_environment: z15.boolean().optional().describe(
3730
+ project_id: z19.union([z19.number().int().positive(), z19.string()]).optional().describe("Project filter \u2014 numeric id or publicId."),
3731
+ environment_id: z19.union([z19.number().int().positive(), z19.string()]).optional().describe("Environment filter \u2014 numeric id or publicId."),
3732
+ status: z19.enum(["active", "deploying", "suspended", "failed", "not_deployed"]).optional().describe("Filter by current runtime status."),
3733
+ type: z19.enum(["web_service", "private_service", "worker", "cron_job", "static_site"]).optional().describe("Filter by service type."),
3734
+ dev_environment: z19.boolean().optional().describe(
2943
3735
  "Include agentic Dev Boxes in the results (excluded by default; see list_dev_environments)."
2944
3736
  )
2945
3737
  },
@@ -2984,6 +3776,8 @@ defineTool({
2984
3776
  "",
2985
3777
  "When to use: the user wants to deploy something new. For a one-command AI dev environment specifically, prefer create_dev_environment (it also attaches the /workspace volume and sets the MCP keys).",
2986
3778
  "",
3779
+ "*** For a packaged app (WordPress, Ghost, n8n, Uptime Kuma, Vaultwarden), start at list_templates and pass template_id. *** Those templates are prebuilt images that only boot with the volume, scratch dirs, uid and companion database the platform attaches from the template id \u2014 the same image passed as a bare docker_image crash-loops on the read-only rootfs, and the fix is a redeploy away rather than an edit away.",
3780
+ "",
2987
3781
  '*** NOT for databases. *** If the user wants Postgres, Redis, MySQL, MariaDB or MongoDB, call create_database \u2014 do NOT create a service with docker_image "postgres:16" / "redis:7" / "mongo" / "mysql". A database deployed as a service is unmanaged: no backups, no version upgrades, no HA, no credential rotation, no metrics, no persistent volume, and nothing injects its URL into your app. `docker_image` here is for YOUR application images (or sidecars), not for datastores the platform already manages.',
2988
3782
  "",
2989
3783
  "Inputs:",
@@ -2997,6 +3791,8 @@ defineTool({
2997
3791
  " - cron_schedule (optional): cron expression \u2014 required for cron_job.",
2998
3792
  ' - publish_path (optional): static-site output dir (e.g. "dist").',
2999
3793
  ' - runtime (optional): "node" | "bun" | "python" | \u2026 (auto-detected from a repo when omitted).',
3794
+ " - port (optional): the port the container listens on (1\u201365535). A source-built service should omit it \u2014 the platform injects $PORT and expects the app to bind that. Set it for a docker_image whose listen port is fixed by the image (WordPress 80, Ghost 2368, n8n 5678): the platform publishes and health-checks this port and never re-reads what the process actually bound, so an image listening elsewhere fails its first deploy on a container that is perfectly healthy.",
3795
+ " - template_id (optional): create from a quickstart template \u2014 an id from list_templates and nothing else. Everything the template brings (volumes, scratch dirs, uid, generated secrets, companion managed database) is resolved server-side and attached before the first deploy fires; there is no way to send those in the body. For an image template, pass its docker_image and port alongside, exactly as list_templates returns them.",
3000
3796
  ' - plan (optional): service size (default "micro").',
3001
3797
  " - environment_id (optional): bind to a specific environment; defaults to the project Production env.",
3002
3798
  " - auto_deploy (optional, default true): trigger the first deploy immediately when a source is present.",
@@ -3004,26 +3800,33 @@ defineTool({
3004
3800
  "",
3005
3801
  "Returns: { service: Service, deployId: number | null }.",
3006
3802
  "",
3007
- 'Example: create_service({ project_id: "prj_abc", name: "api", type: "web_service", github_repo_id: 42 }) \u2192 { service: { publicId: "svc_\u2026" }, deployId: 1234 }'
3803
+ 'Example: create_service({ project_id: "prj_abc", name: "api", type: "web_service", github_repo_id: 42 }) \u2192 { service: { publicId: "svc_\u2026" }, deployId: 1234 }',
3804
+ 'Example (one-click app): create_service({ project_id: "prj_abc", name: "blog", type: "web_service", template_id: "wordpress", docker_image: "wordpress:php8.3-apache", port: 80 }) \u2014 the wp-content volume, the www-data uid, the Apache scratch dirs and the managed MySQL come from the template id.'
3008
3805
  ].join("\n"),
3009
3806
  input: {
3010
- project_id: z15.union([z15.number().int().positive(), z15.string()]).describe("Target project \u2014 numeric id or publicId."),
3011
- name: z15.string().min(1).max(100).describe("Service name (1\u2013100 chars)."),
3012
- type: z15.enum(SERVICE_TYPES).describe("Service type."),
3013
- docker_image: z15.string().max(500).optional().describe(
3807
+ project_id: z19.union([z19.number().int().positive(), z19.string()]).describe("Target project \u2014 numeric id or publicId."),
3808
+ name: z19.string().min(1).max(100).describe("Service name (1\u2013100 chars)."),
3809
+ type: z19.enum(SERVICE_TYPES).describe("Service type."),
3810
+ docker_image: z19.string().max(500).optional().describe(
3014
3811
  "Pre-built APPLICATION image ref. Mutually exclusive with github_repo_id. Not for databases \u2014 use create_database for postgres/redis/mysql/mariadb/mongodb."
3015
3812
  ),
3016
- github_repo_id: z15.number().int().positive().optional().describe("Linked GitHub repo numeric id. Mutually exclusive with docker_image."),
3017
- branch: z15.string().max(200).optional().describe('Git branch (default "main").'),
3018
- install_command: z15.string().max(1e3).optional().describe("Install shell command."),
3019
- build_command: z15.string().max(1e3).optional().describe("Build shell command."),
3020
- start_command: z15.string().max(1e3).optional().describe("Start shell command (required for web/private services without an image)."),
3021
- cron_schedule: z15.string().max(100).optional().describe("Cron expression \u2014 required for cron_job."),
3022
- publish_path: z15.string().max(500).optional().describe("Static-site output dir."),
3023
- runtime: z15.string().max(50).optional().describe("Runtime hint (node/bun/python/\u2026)."),
3024
- plan: z15.enum(SERVICE_PLANS).optional().describe('Service size (default "micro").'),
3025
- environment_id: z15.union([z15.number().int().positive(), z15.string()]).optional().describe("Bind to a specific environment; defaults to Production."),
3026
- auto_deploy: z15.boolean().optional().describe("Trigger the first deploy immediately (default true)."),
3813
+ github_repo_id: z19.number().int().positive().optional().describe("Linked GitHub repo numeric id. Mutually exclusive with docker_image."),
3814
+ branch: z19.string().max(200).optional().describe('Git branch (default "main").'),
3815
+ install_command: z19.string().max(1e3).optional().describe("Install shell command."),
3816
+ build_command: z19.string().max(1e3).optional().describe("Build shell command."),
3817
+ start_command: z19.string().max(1e3).optional().describe("Start shell command (required for web/private services without an image)."),
3818
+ cron_schedule: z19.string().max(100).optional().describe("Cron expression \u2014 required for cron_job."),
3819
+ publish_path: z19.string().max(500).optional().describe("Static-site output dir."),
3820
+ runtime: z19.string().max(50).optional().describe("Runtime hint (node/bun/python/\u2026)."),
3821
+ port: z19.number().int().min(1).max(65535).optional().describe(
3822
+ "Listen port, for a prebuilt image whose port is fixed by the image. Omit for a source-built service \u2014 it binds the injected $PORT."
3823
+ ),
3824
+ template_id: z19.string().max(64).optional().describe(
3825
+ "Quickstart template id from list_templates. Its volumes, scratch dirs, uid, secrets and companion database are resolved server-side; for an image template also pass its docker_image and port."
3826
+ ),
3827
+ plan: z19.enum(SERVICE_PLANS).optional().describe('Service size (default "micro").'),
3828
+ environment_id: z19.union([z19.number().int().positive(), z19.string()]).optional().describe("Bind to a specific environment; defaults to Production."),
3829
+ auto_deploy: z19.boolean().optional().describe("Trigger the first deploy immediately (default true)."),
3027
3830
  machine: machineInput
3028
3831
  },
3029
3832
  handler: async (args2, ctx) => {
@@ -3046,6 +3849,8 @@ defineTool({
3046
3849
  if (args2.cron_schedule !== void 0) input.cronSchedule = args2.cron_schedule;
3047
3850
  if (args2.publish_path !== void 0) input.publishPath = args2.publish_path;
3048
3851
  if (args2.runtime !== void 0) input.runtime = args2.runtime;
3852
+ if (args2.port !== void 0) input.port = args2.port;
3853
+ if (args2.template_id !== void 0) input.templateId = args2.template_id;
3049
3854
  if (args2.plan !== void 0) input.plan = args2.plan;
3050
3855
  if (args2.auto_deploy !== void 0) input.autoDeploy = args2.auto_deploy;
3051
3856
  if (args2.environment_id !== void 0) {
@@ -3092,18 +3897,18 @@ defineTool({
3092
3897
  'Example: create_dev_environment({ project_id: "prj_abc", name: "scratch", hoststack_api_key: "hs_live_\u2026" })'
3093
3898
  ].join("\n"),
3094
3899
  input: {
3095
- project_id: z15.union([z15.number().int().positive(), z15.string()]).describe("Target project \u2014 numeric id or publicId."),
3096
- name: z15.string().min(1).max(100).optional().describe('Service name (default "dev-environment").'),
3097
- plan: z15.enum(SERVICE_PLANS).optional().describe(
3900
+ project_id: z19.union([z19.number().int().positive(), z19.string()]).describe("Target project \u2014 numeric id or publicId."),
3901
+ name: z19.string().min(1).max(100).optional().describe('Service name (default "dev-environment").'),
3902
+ plan: z19.enum(SERVICE_PLANS).optional().describe(
3098
3903
  'Box size (default "standard" \u2014 2 GB, the OOM-safe floor; a smaller plan is clamped up to "standard").'
3099
3904
  ),
3100
- disk_gb: z15.number().int().min(10).max(10240).optional().describe("/workspace volume size in GB (default 10, min 10, max 10240)."),
3101
- hoststack_api_key: z15.string().optional().describe("Value for HOSTSTACK_API_KEY (enables the hoststack MCP in-container)."),
3102
- poststack_api_key: z15.string().optional().describe("Value for POSTSTACK_API_KEY (enables the poststack MCP in-container)."),
3103
- repo_url: z15.string().max(500).optional().describe(
3905
+ disk_gb: z19.number().int().min(10).max(10240).optional().describe("/workspace volume size in GB (default 10, min 10, max 10240)."),
3906
+ hoststack_api_key: z19.string().optional().describe("Value for HOSTSTACK_API_KEY (enables the hoststack MCP in-container)."),
3907
+ poststack_api_key: z19.string().optional().describe("Value for POSTSTACK_API_KEY (enables the poststack MCP in-container)."),
3908
+ repo_url: z19.string().max(500).optional().describe(
3104
3909
  "Clone this git URL into /workspace on first boot (HTTPS, or SSH once a key is set)."
3105
3910
  ),
3106
- branch: z15.string().max(200).optional().describe("Branch to clone (with repo_url)."),
3911
+ branch: z19.string().max(200).optional().describe("Branch to clone (with repo_url)."),
3107
3912
  machine: machineInput
3108
3913
  },
3109
3914
  handler: async (args2, ctx) => {
@@ -3220,9 +4025,9 @@ defineTool({
3220
4025
  'Example: spin_up_dev_environment({ service_id: "svc_api" }) \u2192 a dev box running a clone of the api service (repo + env-vars + cloned DB) with a public dev URL.'
3221
4026
  ].join("\n"),
3222
4027
  input: {
3223
- service_id: z15.union([z15.number().int().positive(), z15.string()]).describe("Source service to debug \u2014 numeric id or publicId."),
3224
- include_database_clone: z15.boolean().optional().describe("Clone the linked database so the app runs on copied data (default true)."),
3225
- name: z15.string().min(1).max(100).optional().describe('Dev box name (default "<source>-dev").')
4028
+ service_id: z19.union([z19.number().int().positive(), z19.string()]).describe("Source service to debug \u2014 numeric id or publicId."),
4029
+ include_database_clone: z19.boolean().optional().describe("Clone the linked database so the app runs on copied data (default true)."),
4030
+ name: z19.string().min(1).max(100).optional().describe('Dev box name (default "<source>-dev").')
3226
4031
  },
3227
4032
  handler: async (args2, ctx) => {
3228
4033
  const teamId = await ctx.resolveTeamId();
@@ -3263,7 +4068,7 @@ defineTool({
3263
4068
  'Example: delete_dev_environment({ service_id: "svc_api_dev" }) \u2192 removes the dev box, its cloned database, and the /workspace volume.'
3264
4069
  ].join("\n"),
3265
4070
  input: {
3266
- service_id: z15.union([z15.number().int().positive(), z15.string()]).describe("The dev box to tear down \u2014 numeric id or publicId.")
4071
+ service_id: z19.union([z19.number().int().positive(), z19.string()]).describe("The dev box to tear down \u2014 numeric id or publicId.")
3267
4072
  },
3268
4073
  handler: async (args2, ctx) => {
3269
4074
  const teamId = await ctx.resolveTeamId();
@@ -3299,8 +4104,8 @@ defineTool({
3299
4104
  'Example: resize_dev_environment({ service_id: "svc_skyskraber_dev", size: "large" }) \u2192 bumps the box to the large tier, applied live.'
3300
4105
  ].join("\n"),
3301
4106
  input: {
3302
- service_id: z15.union([z15.number().int().positive(), z15.string()]).describe("The box to resize \u2014 numeric id or publicId."),
3303
- size: z15.enum(SERVICE_PLANS).describe(
4107
+ service_id: z19.union([z19.number().int().positive(), z19.string()]).describe("The box to resize \u2014 numeric id or publicId."),
4108
+ size: z19.enum(SERVICE_PLANS).describe(
3304
4109
  'Target size tier (service catalog size, e.g. "standard", "large", "xlarge").'
3305
4110
  )
3306
4111
  },
@@ -3354,13 +4159,13 @@ defineTool({
3354
4159
  name: "list_templates",
3355
4160
  category: "services",
3356
4161
  description: [
3357
- "List the app quickstart templates the dashboard New-Service wizard offers, plus the separate Dev Box preset. A template is a pre-filled install/build/start command set for a stack (Next.js, Django, SvelteKit, Spring Boot, \u2026) \u2014 pass its fields straight to `create_service` to get a service configured exactly like a hand-created one.",
4162
+ "List the app quickstart templates the dashboard New-Service wizard offers, plus the separate Dev Box preset. Two kinds live in one list: a stack template (Next.js, Django, SvelteKit, Spring Boot, \u2026) is a pre-filled install/build/start command set you pair with a Git repo, and a one-click app (WordPress, Ghost, n8n, Uptime Kuma, Vaultwarden) is a prebuilt image that needs no repo at all.",
3358
4163
  "",
3359
- 'When to use: whenever asked to deploy a known stack ("deploy my Next.js app", "put this Django project on HostStack"), before hand-writing a start command; and before creating a Dev Box, to confirm the image, /workspace size, plan floor, and companion engines instead of duplicating those constants.',
4164
+ 'When to use: whenever asked to deploy a known stack ("deploy my Next.js app", "put this Django project on HostStack"), before hand-writing a start command; whenever asked for a packaged app ("deploy WordPress", "I want a Ghost blog"), before reaching for a bare docker_image; and before creating a Dev Box, to confirm the image, /workspace size, plan floor, and companion engines instead of duplicating those constants.',
3360
4165
  "",
3361
- "Returns: { templates: [{ id, name, description, type, runtime, installCommand?, buildCommand?, startCommand?, publishPath?, cronSchedule? }], devBox: { id, name, image, volume: { name, mountPath, sizeGb }, minPlan, createWith, companions, inBox } }. Every template is source-built from a Git repo \u2014 pair one with `github_repo_id`, not `docker_image`. The Dev Box is NOT in `templates`: it is created with create_standalone_dev_environment / create_dev_environment, never create_service. `companions` are SEPARATE managed databases you can attach; `inBox` is what the box already runs on its own (engines via `dev-services`, language runtimes via `dev-runtime`) \u2014 check it before concluding a box cannot run something.",
4166
+ "Returns: { templates: [{ id, name, description, type, runtime?, installCommand?, buildCommand?, startCommand?, publishPath?, cronSchedule?, dockerImage?, port?, requiresTemplateId? }], devBox: { id, name, image, volume: { name, mountPath, sizeGb }, minPlan, createWith, companions, inBox } }. A template WITHOUT `dockerImage` is source-built \u2014 pass its commands to create_service with a `github_repo_id`. A template WITH `dockerImage` is flagged `requiresTemplateId` and must be created as create_service({ template_id, docker_image, port, \u2026 }): the volume, scratch dirs, uid, generated secrets and companion managed database it needs are attached server-side from the id, and the same image sent without it crash-loops on the read-only rootfs. The Dev Box is NOT in `templates`: it is created with create_standalone_dev_environment / create_dev_environment, never create_service. `companions` are SEPARATE managed databases you can attach; `inBox` is what the box already runs on its own (engines via `dev-services`, language runtimes via `dev-runtime`) \u2014 check it before concluding a box cannot run something.",
3362
4167
  "",
3363
- 'Example: list_templates() \u2192 { templates: [{ id: "nextjs-ssr", name: "Next.js", type: "web_service", runtime: "node", installCommand: "npm install", buildCommand: "npm run build", startCommand: "npm run start" }, \u2026], devBox: { id: "dev-environment", image: "registry.hoststack.dev/hoststack/dev-env:latest", volume: { mountPath: "/workspace", sizeGb: 10 }, minPlan: "standard", companions: ["postgres","redis","meilisearch"] } }'
4168
+ 'Example: list_templates() \u2192 { templates: [{ id: "nextjs-ssr", name: "Next.js", type: "web_service", runtime: "node", installCommand: "npm install", buildCommand: "npm run build", startCommand: "npm run start" }, { id: "wordpress", name: "WordPress", type: "web_service", dockerImage: "wordpress:php8.3-apache", port: 80, requiresTemplateId: true }, \u2026], devBox: { id: "dev-environment", image: "registry.hoststack.dev/hoststack/dev-env:latest", volume: { mountPath: "/workspace", sizeGb: 10 }, minPlan: "standard", companions: ["postgres","redis","meilisearch"] } }'
3364
4169
  ].join("\n"),
3365
4170
  input: {},
3366
4171
  handler: async () => {
@@ -3401,12 +4206,14 @@ defineTool({
3401
4206
  }
3402
4207
  }
3403
4208
  };
3404
- const templates = MCP_APP_TEMPLATES;
4209
+ const templates = MCP_APP_TEMPLATES.map(
4210
+ (t) => t.dockerImage ? { ...t, requiresTemplateId: true } : t
4211
+ );
3405
4212
  const byType = /* @__PURE__ */ new Map();
3406
4213
  for (const t of templates) byType.set(t.type, (byType.get(t.type) ?? 0) + 1);
3407
4214
  const breakdown = [...byType].map(([type, n]) => `${n} ${type}`).join(", ");
3408
4215
  return respond({
3409
- summary: `${templates.length} app templates (${breakdown}) \u2014 pick one by id and pass its commands to create_service with a github_repo_id. Plus the Dev Box preset (image ${DEV_ENV_IMAGE}, /workspace ${DEV_ENV_VOLUME.sizeGb} GB, floor "${DEV_ENV_MIN_SIZE}"), which is created with create_standalone_dev_environment, not create_service.`,
4216
+ summary: `${templates.length} app templates (${breakdown}) \u2014 pick one by id, then either pass its commands to create_service with a github_repo_id, or, if it has a dockerImage, pass template_id + docker_image + port. Plus the Dev Box preset (image ${DEV_ENV_IMAGE}, /workspace ${DEV_ENV_VOLUME.sizeGb} GB, floor "${DEV_ENV_MIN_SIZE}"), which is created with create_standalone_dev_environment, not create_service.`,
3410
4217
  data: { templates, devBox }
3411
4218
  });
3412
4219
  }
@@ -3441,21 +4248,21 @@ defineTool({
3441
4248
  'Example: create_standalone_dev_environment({ name: "app-dev", source_kind: "github_repo", github_repo_id: 42, databases: ["postgres","redis"] })'
3442
4249
  ].join("\n"),
3443
4250
  input: {
3444
- name: z15.string().min(1).max(100).optional().describe(
4251
+ name: z19.string().min(1).max(100).optional().describe(
3445
4252
  'Dev Box name. Omit to have it named after the source (the repo name, or "dev-box" for a blank one), de-duplicated against existing boxes.'
3446
4253
  ),
3447
- source_kind: z15.enum(["github_repo", "url", "blank"]).describe("Where the code comes from."),
3448
- github_repo_id: z15.number().int().positive().optional().describe('Connected GitHub repo id (required when source_kind="github_repo").'),
3449
- clone_url: z15.string().url().optional().describe('http(s) git clone URL (required when source_kind="url").'),
3450
- branch: z15.string().min(1).max(255).optional().describe("Branch to clone."),
3451
- databases: z15.array(z15.enum(["postgres", "redis", "meilisearch"])).optional().describe("Companion services to attach (fresh + empty)."),
3452
- plan: z15.enum(SERVICE_PLANS).optional().describe(
4254
+ source_kind: z19.enum(["github_repo", "url", "blank"]).describe("Where the code comes from."),
4255
+ github_repo_id: z19.number().int().positive().optional().describe('Connected GitHub repo id (required when source_kind="github_repo").'),
4256
+ clone_url: z19.string().url().optional().describe('http(s) git clone URL (required when source_kind="url").'),
4257
+ branch: z19.string().min(1).max(255).optional().describe("Branch to clone."),
4258
+ databases: z19.array(z19.enum(["postgres", "redis", "meilisearch"])).optional().describe("Companion services to attach (fresh + empty)."),
4259
+ plan: z19.enum(SERVICE_PLANS).optional().describe(
3453
4260
  'Box size (default "standard" \u2014 2 GB; a smaller plan is floored to "standard").'
3454
4261
  ),
3455
- agent_accounts: z15.array(
3456
- z15.object({
3457
- provider: z15.enum(["claude", "codex", "opencode"]),
3458
- account_id: z15.number().int().positive()
4262
+ agent_accounts: z19.array(
4263
+ z19.object({
4264
+ provider: z19.enum(["claude", "codex", "opencode"]),
4265
+ account_id: z19.number().int().positive()
3459
4266
  })
3460
4267
  ).max(3).optional().describe(
3461
4268
  "Bind saved agent logins by account id per provider. Omit to inherit the box owner's default logins automatically."
@@ -3533,7 +4340,7 @@ defineTool({
3533
4340
  'Example: get_service({ service_id: "svc_abc" }) \u2192 { service: { type: "web", status: "running", \u2026 }, config: { healthCheckGracePeriodSec: 120, \u2026 } }'
3534
4341
  ].join("\n"),
3535
4342
  input: {
3536
- service_id: z15.string().describe("Service publicId (e.g. svc_abc123).")
4343
+ service_id: z19.string().describe("Service publicId (e.g. svc_abc123).")
3537
4344
  },
3538
4345
  handler: async (args2, ctx) => {
3539
4346
  const teamId = await ctx.resolveTeamId();
@@ -3565,7 +4372,7 @@ defineTool({
3565
4372
  'Example: get_service_metrics({ service_id: "svc_abc" }) \u2192 { metrics: { cpu: 0.42, memory: 0.71, \u2026 } }'
3566
4373
  ].join("\n"),
3567
4374
  input: {
3568
- service_id: z15.string().describe("Service publicId.")
4375
+ service_id: z19.string().describe("Service publicId.")
3569
4376
  },
3570
4377
  handler: async (args2, ctx) => {
3571
4378
  const teamId = await ctx.resolveTeamId();
@@ -3594,9 +4401,9 @@ defineTool({
3594
4401
  'Example: get_service_metrics_history({ service_id: "svc_abc", from: "-1h" }) \u2192 60-ish points for the last hour.'
3595
4402
  ].join("\n"),
3596
4403
  input: {
3597
- service_id: z15.string().describe("Service publicId."),
3598
- from: z15.string().optional().describe('ISO-8601 lower bound or relative offset (e.g. "-1h", "-2d").'),
3599
- to: z15.string().optional().describe("ISO-8601 upper bound; defaults to now.")
4404
+ service_id: z19.string().describe("Service publicId."),
4405
+ from: z19.string().optional().describe('ISO-8601 lower bound or relative offset (e.g. "-1h", "-2d").'),
4406
+ to: z19.string().optional().describe("ISO-8601 upper bound; defaults to now.")
3600
4407
  },
3601
4408
  handler: async (args2, ctx) => {
3602
4409
  const teamId = await ctx.resolveTeamId();
@@ -3640,8 +4447,8 @@ defineTool({
3640
4447
  'Example: update_service({ service_id: "svc_abc", name: "api-prod" }) \u2192 { service: { name: "api-prod", \u2026 } }'
3641
4448
  ].join("\n"),
3642
4449
  input: {
3643
- service_id: z15.string().describe("Service publicId."),
3644
- name: z15.string().min(1).max(60).describe("New service name (1\u201360 chars).")
4450
+ service_id: z19.string().describe("Service publicId."),
4451
+ name: z19.string().min(1).max(60).describe("New service name (1\u201360 chars).")
3645
4452
  },
3646
4453
  handler: async (args2, ctx) => {
3647
4454
  const teamId = await ctx.resolveTeamId();
@@ -3690,40 +4497,40 @@ defineTool({
3690
4497
  'Example: update_service_config({ service_id: "svc_abc", health_check_grace_period_sec: 180 }) \u2192 { config: { healthCheckGracePeriodSec: 180, \u2026 } }'
3691
4498
  ].join("\n"),
3692
4499
  input: {
3693
- service_id: z15.string().describe("Service publicId."),
3694
- install_command: z15.string().nullable().optional().describe("Install shell command. Null clears."),
3695
- build_command: z15.string().nullable().optional().describe("Build shell command. Null clears."),
3696
- start_command: z15.string().nullable().optional().describe("Start shell command. Null clears."),
3697
- branch: z15.string().optional().describe("Git branch to track."),
3698
- root_directory: z15.string().optional().describe("Build context root."),
3699
- dockerfile_path: z15.string().nullable().optional().describe("Path to Dockerfile relative to root. Null clears."),
3700
- auto_deploy: z15.boolean().optional().describe("Auto-deploy on push."),
3701
- health_check_path: z15.string().nullable().optional().describe('HTTP health-check path (e.g. "/health"). Null = TCP-only check.'),
3702
- health_check_enabled: z15.boolean().optional().describe("Toggle health checking on/off."),
3703
- health_check_interval: z15.number().int().min(5).max(300).optional().describe("How often the check runs, in seconds (5\u2013300)."),
3704
- health_check_timeout: z15.number().int().min(1).max(60).optional().describe("Single-attempt timeout in seconds (1\u201360)."),
3705
- health_check_grace_period_sec: z15.number().int().min(1).max(1800).optional().describe(
4500
+ service_id: z19.string().describe("Service publicId."),
4501
+ install_command: z19.string().nullable().optional().describe("Install shell command. Null clears."),
4502
+ build_command: z19.string().nullable().optional().describe("Build shell command. Null clears."),
4503
+ start_command: z19.string().nullable().optional().describe("Start shell command. Null clears."),
4504
+ branch: z19.string().optional().describe("Git branch to track."),
4505
+ root_directory: z19.string().optional().describe("Build context root."),
4506
+ dockerfile_path: z19.string().nullable().optional().describe("Path to Dockerfile relative to root. Null clears."),
4507
+ auto_deploy: z19.boolean().optional().describe("Auto-deploy on push."),
4508
+ health_check_path: z19.string().nullable().optional().describe('HTTP health-check path (e.g. "/health"). Null = TCP-only check.'),
4509
+ health_check_enabled: z19.boolean().optional().describe("Toggle health checking on/off."),
4510
+ health_check_interval: z19.number().int().min(5).max(300).optional().describe("How often the check runs, in seconds (5\u2013300)."),
4511
+ health_check_timeout: z19.number().int().min(1).max(60).optional().describe("Single-attempt timeout in seconds (1\u201360)."),
4512
+ health_check_grace_period_sec: z19.number().int().min(1).max(1800).optional().describe(
3706
4513
  "Startup grace period in seconds (1\u20131800). Raise this if the app needs more time to boot before health checks start counting failures."
3707
4514
  ),
3708
- memory_mb: z15.number().int().min(128).max(16384).optional().describe("Container memory cap in MB (128\u201316384)."),
3709
- cpu_shares: z15.number().int().min(128).max(4096).optional().describe("Relative CPU weight (128\u20134096)."),
3710
- disk_size_gb: z15.number().int().min(1).max(100).optional().describe("Ephemeral disk size in GB (1\u2013100)."),
3711
- port: z15.number().int().min(1).max(65535).optional().describe("Container port the platform forwards traffic to."),
3712
- protocol: z15.enum(["http", "tcp"]).optional().describe("Traffic protocol."),
3713
- restart_policy: z15.enum(["always", "on-failure", "no"]).optional().describe("Docker restart policy."),
3714
- deploy_strategy: z15.enum(["rolling", "recreate"]).optional().describe(
4515
+ memory_mb: z19.number().int().min(128).max(16384).optional().describe("Container memory cap in MB (128\u201316384)."),
4516
+ cpu_shares: z19.number().int().min(128).max(4096).optional().describe("Relative CPU weight (128\u20134096)."),
4517
+ disk_size_gb: z19.number().int().min(1).max(100).optional().describe("Ephemeral disk size in GB (1\u2013100)."),
4518
+ port: z19.number().int().min(1).max(65535).optional().describe("Container port the platform forwards traffic to."),
4519
+ protocol: z19.enum(["http", "tcp"]).optional().describe("Traffic protocol."),
4520
+ restart_policy: z19.enum(["always", "on-failure", "no"]).optional().describe("Docker restart policy."),
4521
+ deploy_strategy: z19.enum(["rolling", "recreate"]).optional().describe(
3715
4522
  'How a deploy replaces the container. "rolling" (default) = start new, wait for healthy, switch traffic, stop old (zero downtime). "recreate" = stop old first, then start new (brief outage) \u2014 required for a container holding an exclusive lock on a mounted volume, which cannot deploy at all under rolling.'
3716
4523
  ),
3717
- pre_deploy_command: z15.string().optional().describe("Shell command run before the new release accepts traffic."),
3718
- instance_count: z15.number().int().positive().max(50).optional().describe("Pin min and max instances to this value (1\u201350)."),
3719
- min_instances: z15.number().int().min(0).max(50).optional().describe("Autoscale lower bound. Use with max_instances for a range."),
3720
- max_instances: z15.number().int().min(1).max(50).optional().describe("Autoscale upper bound. Use with min_instances for a range."),
3721
- scale_cpu_threshold: z15.number().int().min(10).max(100).optional().describe("Autoscale CPU trigger percentage (10\u2013100)."),
3722
- scale_memory_threshold: z15.number().int().min(10).max(100).optional().describe("Autoscale memory trigger percentage (10\u2013100)."),
3723
- log_filter_rules: z15.array(
3724
- z15.object({
3725
- pattern: z15.string().min(1).max(200),
3726
- action: z15.enum(["drop", "downgrade"])
4524
+ pre_deploy_command: z19.string().optional().describe("Shell command run before the new release accepts traffic."),
4525
+ instance_count: z19.number().int().positive().max(50).optional().describe("Pin min and max instances to this value (1\u201350)."),
4526
+ min_instances: z19.number().int().min(0).max(50).optional().describe("Autoscale lower bound. Use with max_instances for a range."),
4527
+ max_instances: z19.number().int().min(1).max(50).optional().describe("Autoscale upper bound. Use with min_instances for a range."),
4528
+ scale_cpu_threshold: z19.number().int().min(10).max(100).optional().describe("Autoscale CPU trigger percentage (10\u2013100)."),
4529
+ scale_memory_threshold: z19.number().int().min(10).max(100).optional().describe("Autoscale memory trigger percentage (10\u2013100)."),
4530
+ log_filter_rules: z19.array(
4531
+ z19.object({
4532
+ pattern: z19.string().min(1).max(200),
4533
+ action: z19.enum(["drop", "downgrade"])
3727
4534
  })
3728
4535
  ).max(50).optional().describe(
3729
4536
  "Runtime-log filter rules. Empty array [] clears all rules. Each pattern is case-insensitive substring match against the message."
@@ -3820,7 +4627,7 @@ defineTool({
3820
4627
  'Example: suspend_service({ service_id: "svc_dev" }) \u2192 { ok: true }'
3821
4628
  ].join("\n"),
3822
4629
  input: {
3823
- service_id: z15.string().describe("Service publicId.")
4630
+ service_id: z19.string().describe("Service publicId.")
3824
4631
  },
3825
4632
  handler: async (args2, ctx) => {
3826
4633
  const teamId = await ctx.resolveTeamId();
@@ -3844,7 +4651,7 @@ defineTool({
3844
4651
  'Example: resume_service({ service_id: "svc_dev" }) \u2192 { ok: true }'
3845
4652
  ].join("\n"),
3846
4653
  input: {
3847
- service_id: z15.string().describe("Service publicId.")
4654
+ service_id: z19.string().describe("Service publicId.")
3848
4655
  },
3849
4656
  handler: async (args2, ctx) => {
3850
4657
  const teamId = await ctx.resolveTeamId();
@@ -3870,7 +4677,7 @@ defineTool({
3870
4677
  'Example: delete_service({ service_id: "svc_abandoned" }) \u2192 { ok: true }'
3871
4678
  ].join("\n"),
3872
4679
  input: {
3873
- service_id: z15.string().describe("Service publicId.")
4680
+ service_id: z19.string().describe("Service publicId.")
3874
4681
  },
3875
4682
  handler: async (args2, ctx) => {
3876
4683
  const teamId = await ctx.resolveTeamId();
@@ -3907,16 +4714,16 @@ defineTool({
3907
4714
  ' - Just count error lines without fetching them: get_service_logs({ service_id: "svc_abc", level: "error", since: "-5m", count_only: true }) \u2192 { count: 47 }'
3908
4715
  ].join("\n"),
3909
4716
  input: {
3910
- service_id: z15.string().describe("Service publicId."),
3911
- lines: z15.number().int().positive().max(1e3).optional().describe("Tail size; default 200, hard cap 1000."),
3912
- since: z15.string().optional().describe('ISO-8601 timestamp or relative offset (e.g. "-5m", "-1h").'),
3913
- until: z15.string().optional().describe("ISO-8601 timestamp or relative offset upper bound."),
3914
- stream: z15.enum(["stdout", "stderr"]).optional().describe("Restrict to one stream."),
3915
- level: z15.enum(["stdout", "stderr", "trace", "debug", "info", "warn", "error", "fatal"]).optional().describe(
4717
+ service_id: z19.string().describe("Service publicId."),
4718
+ lines: z19.number().int().positive().max(1e3).optional().describe("Tail size; default 200, hard cap 1000."),
4719
+ since: z19.string().optional().describe('ISO-8601 timestamp or relative offset (e.g. "-5m", "-1h").'),
4720
+ until: z19.string().optional().describe("ISO-8601 timestamp or relative offset upper bound."),
4721
+ stream: z19.enum(["stdout", "stderr"]).optional().describe("Restrict to one stream."),
4722
+ level: z19.enum(["stdout", "stderr", "trace", "debug", "info", "warn", "error", "fatal"]).optional().describe(
3916
4723
  "Filter by structured JSON log level (pino/bunyan/severity). Falls back to a stream-alias hint for plain-text logs (info/debug\u2192stdout, warn/error/fatal\u2192stderr)."
3917
4724
  ),
3918
- search: z15.string().max(100).optional().describe("Case-insensitive substring filter."),
3919
- count_only: z15.boolean().optional().describe("When true, return only { count } \u2014 skips the log payload.")
4725
+ search: z19.string().max(100).optional().describe("Case-insensitive substring filter."),
4726
+ count_only: z19.boolean().optional().describe("When true, return only { count } \u2014 skips the log payload.")
3920
4727
  },
3921
4728
  handler: async (args2, ctx) => {
3922
4729
  const teamId = await ctx.resolveTeamId();
@@ -3965,14 +4772,14 @@ defineTool({
3965
4772
  'Example: get_service_logs_bulk({ service_ids: ["svc_api", "svc_worker"], level: "error", since: "-15m", count_only: true }) \u2192 { results: { svc_api: { count: 0 }, svc_worker: { count: 12 } } }.'
3966
4773
  ].join("\n"),
3967
4774
  input: {
3968
- service_ids: z15.array(z15.string()).min(1).max(10).describe("Service publicIds (1\u201310). Hard cap 10 to bound parallel work."),
3969
- lines_per_service: z15.number().int().positive().max(500).optional().describe("Tail size per service; default 100, hard cap 500."),
3970
- since: z15.string().optional().describe('ISO-8601 timestamp or relative offset (e.g. "-5m", "-1h").'),
3971
- until: z15.string().optional().describe("ISO-8601 timestamp or relative offset upper bound."),
3972
- stream: z15.enum(["stdout", "stderr"]).optional().describe("Restrict to one stream."),
3973
- level: z15.enum(["stdout", "stderr", "trace", "debug", "info", "warn", "error", "fatal"]).optional().describe("Structured log level filter (same as get_service_logs)."),
3974
- search: z15.string().max(100).optional().describe("Case-insensitive substring filter."),
3975
- count_only: z15.boolean().optional().describe("When true, return only counts per service \u2014 skips the log payload.")
4775
+ service_ids: z19.array(z19.string()).min(1).max(10).describe("Service publicIds (1\u201310). Hard cap 10 to bound parallel work."),
4776
+ lines_per_service: z19.number().int().positive().max(500).optional().describe("Tail size per service; default 100, hard cap 500."),
4777
+ since: z19.string().optional().describe('ISO-8601 timestamp or relative offset (e.g. "-5m", "-1h").'),
4778
+ until: z19.string().optional().describe("ISO-8601 timestamp or relative offset upper bound."),
4779
+ stream: z19.enum(["stdout", "stderr"]).optional().describe("Restrict to one stream."),
4780
+ level: z19.enum(["stdout", "stderr", "trace", "debug", "info", "warn", "error", "fatal"]).optional().describe("Structured log level filter (same as get_service_logs)."),
4781
+ search: z19.string().max(100).optional().describe("Case-insensitive substring filter."),
4782
+ count_only: z19.boolean().optional().describe("When true, return only counts per service \u2014 skips the log payload.")
3976
4783
  },
3977
4784
  handler: async (args2, ctx) => {
3978
4785
  const teamId = await ctx.resolveTeamId();
@@ -4017,8 +4824,119 @@ defineTool({
4017
4824
  }
4018
4825
  });
4019
4826
 
4827
+ // src/tools/uptime.ts
4828
+ import { z as z20 } from "zod";
4829
+ var STATUS_MEANING = [
4830
+ "Status vocabulary: `up` answering as expected \xB7 `down` failed the threshold and an alert is open \xB7 `unknown` not probed yet \xB7 `unresolvable` the service has no active domain to request \xB7 `paused` the service is not meant to be answering (suspended, mid-deploy, never deployed) OR it sleeps when idle and probing it would keep it awake."
4831
+ ].join("\n");
4832
+ defineTool({
4833
+ name: "get_uptime_check",
4834
+ category: "uptime",
4835
+ description: [
4836
+ "Read a service's uptime check \u2014 HostStack requesting the service's public URL on a schedule and alerting when it stops answering.",
4837
+ "",
4838
+ 'When to use: "is this service actually reachable", or before changing a check to see what it currently does. This is NOT the deploy-time health check: that one watches the container from inside the host and stops mattering once a deploy is live. This one runs from the control plane against the public URL, so it also catches DNS, TLS, edge and routing failures \u2014 and a service that accepts the connection and then answers nothing.',
4839
+ "",
4840
+ STATUS_MEANING,
4841
+ "",
4842
+ "Inputs: serviceId (required).",
4843
+ "",
4844
+ 'Returns: { check } or { check: null } when none is configured. The check carries path, method, expectedStatus, intervalSeconds, failureThreshold, status, consecutiveFailures, lastCheckedAt, lastStatusCode, lastLatencyMs, lastError, lastChangedAt (the "down since" timestamp).',
4845
+ "",
4846
+ "Example: get_uptime_check({ serviceId: 48 }) \u2192 { check: { path: '/healthz', status: 'down', consecutiveFailures: 5, lastError: 'No response within 10000ms', lastChangedAt: '2026-08-21T04:12:00Z' } }."
4847
+ ].join("\n"),
4848
+ input: { serviceId: z20.number().int().positive() },
4849
+ handler: async (args2, ctx) => {
4850
+ const teamId = await ctx.resolveTeamId();
4851
+ const response = await ctx.api.get(
4852
+ `/api/services/${teamId}/${args2.serviceId}/uptime-check`
4853
+ );
4854
+ if (response.check === null) {
4855
+ return respond({
4856
+ summary: "No uptime check on this service \u2014 nothing is watching its public URL.",
4857
+ data: { check: null }
4858
+ });
4859
+ }
4860
+ const check = shape(response.check);
4861
+ return respond({
4862
+ summary: `Uptime check is ${String(check["status"])}${check["lastError"] ? ` \u2014 ${String(check["lastError"])}` : ""}.`,
4863
+ data: { check }
4864
+ });
4865
+ }
4866
+ });
4867
+ defineTool({
4868
+ name: "set_uptime_check",
4869
+ category: "uptime",
4870
+ description: [
4871
+ "Create or update a service's uptime check.",
4872
+ "",
4873
+ "When to use: turning monitoring on for a service that has just gone live, or adjusting a check that is too noisy (raise failureThreshold) or too slow to notice (lower intervalSeconds).",
4874
+ "",
4875
+ 'Only service types with a public URL can be checked (web services and static sites). A worker, cron job or private service is refused with 400 \u2014 a check on one could only ever report "no domain to check", which reads as a broken feature rather than an inapplicable one.',
4876
+ "",
4877
+ "For a site HostStack does NOT host, there is no serviceId to pass: use set_site_uptime_check with its analytics siteId instead. That path needs the domain proven first (verify_site_domain), because the host it probes comes from the site row rather than from a domain the platform already vouches for.",
4878
+ "",
4879
+ "IMPORTANT: changing the shape of a check RESETS its accumulated state (status, consecutive failures, open alert). A check whose path or expected status just changed has not observed the new check failing, so carrying failures forward would alert about a condition that was never measured. Pass the full shape you want, not a partial edit of an unknown current state \u2014 read it with get_uptime_check first if that matters.",
4880
+ "",
4881
+ "`path` is a path, not a URL: the host comes from the service's primary domain at probe time, so moving the service to a new domain moves the check with it.",
4882
+ "",
4883
+ "Method is GET or HEAD only. A probe fires unattended every interval forever, so it has to be safe to repeat \u2014 a check that could POST would be a scheduled writer against the team's own API.",
4884
+ "",
4885
+ "Redirects are NOT followed: a 301 is an answer, and following one can walk the probe onto a marketing site and report a dead service as healthy. If a redirect is expected, set expectedStatus to it.",
4886
+ "",
4887
+ 'Inputs (all optional except serviceId): enabled, path (default "/"), method (GET|HEAD), expectedStatus (default 200), timeoutMs (1000\u201360000, default 10000 \u2014 this is what catches a hung server), intervalSeconds (30\u20133600, default 60), failureThreshold (1\u201310, default 3 \u2014 one failed request is usually a restart, not an outage).',
4888
+ "",
4889
+ "Returns: { check } with the saved check.",
4890
+ "",
4891
+ "Example: set_uptime_check({ serviceId: 48, path: '/healthz', intervalSeconds: 60, failureThreshold: 3 }) \u2192 { check: { status: 'unknown', \u2026 } }."
4892
+ ].join("\n"),
4893
+ input: {
4894
+ serviceId: z20.number().int().positive(),
4895
+ enabled: z20.boolean().optional(),
4896
+ path: z20.string().max(500).optional().describe('Must start with /. Default "/".'),
4897
+ method: z20.enum(["GET", "HEAD"]).optional(),
4898
+ expectedStatus: z20.number().int().min(100).max(599).optional(),
4899
+ timeoutMs: z20.number().int().min(1e3).max(6e4).optional(),
4900
+ intervalSeconds: z20.number().int().min(30).max(3600).optional(),
4901
+ failureThreshold: z20.number().int().min(1).max(10).optional()
4902
+ },
4903
+ handler: async (args2, ctx) => {
4904
+ const teamId = await ctx.resolveTeamId();
4905
+ const { serviceId, ...body } = args2;
4906
+ const response = await ctx.api.put(
4907
+ `/api/services/${teamId}/${serviceId}/uptime-check`,
4908
+ body
4909
+ );
4910
+ return respond({
4911
+ summary: `Uptime check saved. It will start reporting within a minute or two.`,
4912
+ data: { check: shape(response.check) }
4913
+ });
4914
+ }
4915
+ });
4916
+ defineTool({
4917
+ name: "delete_uptime_check",
4918
+ category: "uptime",
4919
+ description: [
4920
+ 'Stop checking a service. Any open "not answering" alert is left as it stands rather than being silently resolved \u2014 removing the monitor is not evidence the service came back.',
4921
+ "",
4922
+ "When to use: the service is being retired, or monitoring has moved somewhere else. To pause a check without losing its configuration, call set_uptime_check with enabled=false instead.",
4923
+ "",
4924
+ "Inputs: serviceId (required).",
4925
+ "",
4926
+ "Returns: { success: true }.",
4927
+ "",
4928
+ "Example: delete_uptime_check({ serviceId: 48 }) \u2192 { success: true }."
4929
+ ].join("\n"),
4930
+ input: { serviceId: z20.number().int().positive() },
4931
+ handler: async (args2, ctx) => {
4932
+ const teamId = await ctx.resolveTeamId();
4933
+ await ctx.api.delete(`/api/services/${teamId}/${args2.serviceId}/uptime-check`);
4934
+ return respond({ summary: "Uptime check removed. Nothing is watching this service now." });
4935
+ }
4936
+ });
4937
+
4020
4938
  // src/tools/volumes.ts
4021
- import { z as z16 } from "zod";
4939
+ import { z as z21 } from "zod";
4022
4940
  var MIN_VOLUME_SIZE_GB = 10;
4023
4941
  defineTool({
4024
4942
  name: "list_volumes",
@@ -4036,7 +4954,7 @@ defineTool({
4036
4954
  'Example: list_volumes({ service_id: "svc_abc" }) \u2192 { items: [{ name: "data", mountPath: "/var/data", sizeGb: 10, status: "active" }] }'
4037
4955
  ].join("\n"),
4038
4956
  input: {
4039
- service_id: z16.string().describe("Service publicId (e.g. svc_abc123).")
4957
+ service_id: z21.string().describe("Service publicId (e.g. svc_abc123).")
4040
4958
  },
4041
4959
  handler: async (args2, ctx) => {
4042
4960
  const teamId = await ctx.resolveTeamId();
@@ -4065,15 +4983,15 @@ defineTool({
4065
4983
  'Example: create_volume({ service_id: "svc_abc", name: "data", mount_path: "/var/data", size_gb: 10 }) \u2192 { volume: { name: "data", mountPath: "/var/data", sizeGb: 10, status: "pending" } }'
4066
4984
  ].join("\n"),
4067
4985
  input: {
4068
- service_id: z16.string().describe("Service publicId."),
4069
- name: z16.string().min(1).max(64).regex(/^[a-z0-9-]+$/).describe("Volume name (lowercase alphanumeric + hyphens)."),
4070
- mount_path: z16.string().startsWith("/").max(500).describe("In-container mount path (absolute)."),
4986
+ service_id: z21.string().describe("Service publicId."),
4987
+ name: z21.string().min(1).max(64).regex(/^[a-z0-9-]+$/).describe("Volume name (lowercase alphanumeric + hyphens)."),
4988
+ mount_path: z21.string().startsWith("/").max(500).describe("In-container mount path (absolute)."),
4071
4989
  // 10 GB is the real floor: the block-storage backend rejects anything
4072
4990
  // smaller. Advertising 1 GB here (and defaulting to it) meant taking the
4073
4991
  // defaults produced a volume that provisioned with `Hetzner API error:
4074
4992
  // 422` on the NEXT deploy, with nothing tying the failure back to the
4075
4993
  // size. Reject it at the call instead.
4076
- size_gb: z16.number().int().min(MIN_VOLUME_SIZE_GB).max(100).optional().describe(
4994
+ size_gb: z21.number().int().min(MIN_VOLUME_SIZE_GB).max(100).optional().describe(
4077
4995
  `Disk size in GB (minimum ${MIN_VOLUME_SIZE_GB}, default ${MIN_VOLUME_SIZE_GB}, max 100 via MCP).`
4078
4996
  )
4079
4997
  },
@@ -4111,10 +5029,10 @@ defineTool({
4111
5029
  'Example: update_volume({ service_id: "svc_abc", volume_id: "vol_xyz", size_gb: 20 }) \u2192 { volume: { sizeGb: 20, \u2026 } }'
4112
5030
  ].join("\n"),
4113
5031
  input: {
4114
- service_id: z16.string().describe("Service publicId."),
4115
- volume_id: z16.string().describe("Volume publicId (e.g. vol_\u2026)."),
4116
- mount_path: z16.string().startsWith("/").max(500).optional().describe("New mount path."),
4117
- size_gb: z16.number().int().min(MIN_VOLUME_SIZE_GB).max(100).optional().describe(`New size in GB (minimum ${MIN_VOLUME_SIZE_GB}, grow-only).`)
5032
+ service_id: z21.string().describe("Service publicId."),
5033
+ volume_id: z21.string().describe("Volume publicId (e.g. vol_\u2026)."),
5034
+ mount_path: z21.string().startsWith("/").max(500).optional().describe("New mount path."),
5035
+ size_gb: z21.number().int().min(MIN_VOLUME_SIZE_GB).max(100).optional().describe(`New size in GB (minimum ${MIN_VOLUME_SIZE_GB}, grow-only).`)
4118
5036
  },
4119
5037
  handler: async (args2, ctx) => {
4120
5038
  const teamId = await ctx.resolveTeamId();
@@ -4152,8 +5070,8 @@ defineTool({
4152
5070
  'Example: delete_volume({ service_id: "svc_abc", volume_id: "vol_xyz" }) \u2192 { ok: true }'
4153
5071
  ].join("\n"),
4154
5072
  input: {
4155
- service_id: z16.string().describe("Service publicId."),
4156
- volume_id: z16.string().describe("Volume publicId.")
5073
+ service_id: z21.string().describe("Service publicId."),
5074
+ volume_id: z21.string().describe("Volume publicId.")
4157
5075
  },
4158
5076
  handler: async (args2, ctx) => {
4159
5077
  const teamId = await ctx.resolveTeamId();