@hoststack.dev/mcp 0.24.0 → 0.26.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.24.0" : "0.0.0-dev";
11
+ var MCP_VERSION = true ? "0.26.0" : "0.0.0-dev";
12
12
  var USER_AGENT = `hoststack-mcp/${MCP_VERSION}`;
13
13
 
14
14
  // src/api-client.ts
@@ -655,6 +655,10 @@ async function resolveMachineId(ctx, teamId, machine) {
655
655
  return matches[0].id;
656
656
  }
657
657
  function describeMachine(m) {
658
+ if (m.kind === "infra") {
659
+ const state = m.status === "active" ? "online" : "offline";
660
+ return `${m.name}: infrastructure machine for project ${m.infraProjectId}, ${state} (only that project's services can be pinned to it)`;
661
+ }
658
662
  if (!m.enrolled) return `${m.name}: registered but never paired`;
659
663
  if (m.status !== "active") return `${m.name}: offline`;
660
664
  if (m.agentBuild === "from-source") return `${m.name}: online, agent running from source`;
@@ -1393,7 +1397,7 @@ var DNS_RECORD_TYPES = [
1393
1397
  async function resolveZonePublicId(hoststack, teamId, input) {
1394
1398
  if (input.zone_id) {
1395
1399
  const { zones: zones2 } = await hoststack.dns.listZones(teamId);
1396
- const match = zones2.find((z22) => z22.publicId === input.zone_id);
1400
+ const match = zones2.find((z23) => z23.publicId === input.zone_id);
1397
1401
  if (!match) {
1398
1402
  throw new Error(`Zone ${input.zone_id} not found on this team.`);
1399
1403
  }
@@ -1407,7 +1411,7 @@ async function resolveZonePublicId(hoststack, teamId, input) {
1407
1411
  const labels = fqdn.split(".");
1408
1412
  for (let i = 0; i < labels.length - 1; i++) {
1409
1413
  const candidate = labels.slice(i).join(".");
1410
- const match = zones.find((z22) => z22.domainName.toLowerCase() === candidate);
1414
+ const match = zones.find((z23) => z23.domainName.toLowerCase() === candidate);
1411
1415
  if (match && match.status !== "deleting") {
1412
1416
  return { publicId: match.publicId, domainName: match.domainName };
1413
1417
  }
@@ -1424,19 +1428,85 @@ defineTool({
1424
1428
  "",
1425
1429
  "When to use: discover which apex domains support record management before calling create_dns_record / list_dns_records. The dashboard equivalent is Domains \u2192 DNS.",
1426
1430
  "",
1427
- 'Returns: { items: Zone[] } \u2014 each zone exposes publicId, domainName, status ("active" | "syncing" | "failed" | "deleting"), nsRecords (the nameservers the parent registry must delegate to), provider, createdAt.',
1431
+ 'Returns: { items: Zone[] } \u2014 each zone exposes publicId, domainName, status ("active" | "syncing" | "failed" | "deleting"), nsRecords (the nameservers the parent registry must delegate to), provider, createdAt, plus the delegation reading below.',
1432
+ "",
1433
+ 'READ delegationStatus, NOT status, to answer "is this domain live?". `status` describes the zone on OUR side \u2014 "active" means PowerDNS holds it and answers for it. `delegationStatus` describes whether the parent registry actually SENDS anyone here:',
1434
+ ' - "delegated" \u2014 the registry names our nameservers; queries reach us.',
1435
+ ' - "foreign" \u2014 the registry names somebody else (delegationObservedNs says who). The zone is authoritative for nobody: its records are correct and unreachable, public lookups return the OLD host or nothing, and no domain under it can pass verification or get a certificate. This is the state a half-finished migration sits in, and it looks identical to a healthy zone on every other field.',
1436
+ ' - "unknown" \u2014 no usable answer (SERVFAIL/timeout/never checked). NOT the same as "foreign"; do not tell a user to change their registrar on the strength of it. Call check_dns_delegation for a live reading.',
1437
+ "",
1438
+ "delegationObservedNs is the NS set the parent publishes today, and delegationCheckedAt is when it was read (refreshed hourly). For an answer current to the second \u2014 e.g. just after a registrar change \u2014 call check_dns_delegation.",
1439
+ "",
1440
+ 'Example: list_dns_zones() \u2192 { items: [{ publicId: "dnz_abc", domainName: "micci.dk", status: "active", nsRecords: ["ns1.hoststack.dev","ns2.hoststack.dev"], delegationStatus: "foreign", delegationObservedNs: ["docks15.rzone.de","shades13.rzone.de"] }] }',
1441
+ "",
1442
+ 'Each zone ALSO carries what the registry says about the REGISTRATION, refreshed daily over RDAP: registrar, registryStatus (EPP codes such as clientHold / pendingTransfer / pendingDelete / redemptionPeriod), registryExpiresAt, registryCheckedAt and registryCheckOutcome ("ok" | "not_found" | "unsupported" | "rate_limited" | "error").',
1428
1443
  "",
1429
- 'Example: list_dns_zones() \u2192 { items: [{ publicId: "dnz_abc", domainName: "micci.dk", status: "active", nsRecords: ["ns1.hoststack.dev","ns2.hoststack.dev"] }] }'
1444
+ 'This is a different question from delegation, and a zone can fail either one alone. Delegation asks whether the world is being sent here; these fields ask whether the domain is still going to be the customer\'s next month. `status: "active"` answers neither \u2014 it describes our nameservers only.',
1445
+ "",
1446
+ 'SILENCE IS NOT GOOD NEWS HERE. A null registryExpiresAt or registrar on a zone that HAS been checked means the registry publishes neither \u2014 DENIC publishes no expiry and no registrar for any .de \u2014 and registryCheckOutcome "unsupported" means the TLD has no RDAP service at all (there is none for .dk), so every field above it is unknowable rather than fine. Read registryCheckOutcome before drawing any conclusion from an empty field.',
1447
+ "",
1448
+ "HostStack is not the registrar for a domain whose DNS it merely hosts. Nothing in this MCP, the CLI or the dashboard can renew, transfer or un-hold a registration \u2014 if registryStatus or registryExpiresAt shows trouble, the action is at the registrar named in `registrar`.",
1449
+ "",
1450
+ 'Example: list_dns_zones() \u2192 { items: [{ publicId: "dnz_abc", domainName: "micci.dk", status: "active", nsRecords: ["ns1.hoststack.dev","ns2.hoststack.dev"], registrar: "GoDaddy.com, LLC", registryStatus: ["clientTransferProhibited"], registryExpiresAt: "2027-03-04T00:00:00Z", registryCheckOutcome: "ok" }] }'
1430
1451
  ].join("\n"),
1431
1452
  input: {},
1432
1453
  handler: async (_args, ctx) => {
1433
1454
  const teamId = await ctx.resolveTeamId();
1434
1455
  const response = await ctx.hoststack.dns.listZones(teamId);
1435
- const items = Array.isArray(response.zones) ? response.zones.map(shape) : [];
1436
- const summary = items.length === 0 ? "No DNS zones hosted on this team. Create one in the dashboard (Domains \u2192 DNS)." : `Found ${items.length} hosted DNS zone${items.length === 1 ? "" : "s"}.`;
1456
+ const zones = Array.isArray(response.zones) ? response.zones : [];
1457
+ const items = zones.map(shape);
1458
+ const foreign = zones.filter((z23) => z23.delegationStatus === "foreign");
1459
+ const foreignNote = foreign.length === 0 ? "" : ` WARNING: ${foreign.length} zone${foreign.length === 1 ? " is" : "s are"} hosted here but still delegated elsewhere by the registry \u2014 ` + foreign.map(
1460
+ (z23) => `${z23.domainName} \u2192 ${(z23.delegationObservedNs ?? []).join(", ") || "unknown nameservers"}`
1461
+ ).join("; ") + `. Records on ${foreign.length === 1 ? "it" : "them"} are correct but unreachable; the nameservers must be changed at the registrar.`;
1462
+ const summary = items.length === 0 ? "No DNS zones hosted on this team. Create one in the dashboard (Domains \u2192 DNS)." : `Found ${items.length} hosted DNS zone${items.length === 1 ? "" : "s"}.${foreignNote}`;
1437
1463
  return respond({ summary, data: { items } });
1438
1464
  }
1439
1465
  });
1466
+ defineTool({
1467
+ name: "check_dns_delegation",
1468
+ category: "dns",
1469
+ description: [
1470
+ "Read, live, whether the parent registry actually delegates a zone's apex to HostStack \u2014 and store the reading on the zone.",
1471
+ "",
1472
+ 'WHAT THIS ANSWERS that nothing else does: a hosted zone can be `status: "active"`, DNSSEC-signed, full of correct records, and authoritative for NOBODY, because the registry still points the domain at the previous host. Public lookups then return the old host (or nothing) while a query against our own nameservers returns the right answer \u2014 and every other field we expose says healthy. This is the normal shape of a half-finished migration, and it is what to check FIRST when a zone looks perfect but the site is not live, a domain will not verify, or a certificate will not issue.',
1473
+ "",
1474
+ "When to use: right after someone changes nameservers at a registrar (list_dns_zones carries the hourly reading, which will be stale by minutes); before telling anyone a cutover is done; or to explain why verify_domain keeps returning pending.",
1475
+ "",
1476
+ "Provide EITHER zone_id (the zone publicId) OR domain (apex or subdomain \u2014 the longest-matching hosted apex wins).",
1477
+ "",
1478
+ "Returns: { zone, delegation: { status, observedNameservers, expectedNameservers, checkedAt } }.",
1479
+ ` - status "delegated": at least one of our nameservers is in the parent's NS set. Any, not all \u2014 a staged migration legitimately runs split for a while, and queries already reach us during it.`,
1480
+ ' - status "foreign": the parent answered and named somebody else. observedNameservers is who. The fix is at the REGISTRAR of the apex, never in our records; expect up to 48h of propagation after the change.',
1481
+ ' - status "unknown": no usable answer (SERVFAIL, timeout, NXDOMAIN). This is NOT "not delegated" \u2014 never tell someone to re-point a registrar on the strength of it. Retry, or check the domain is registered at all.',
1482
+ "",
1483
+ 'Note for `.dk` and other strict registries: they refuse the nameserver change until we already answer authoritatively for the domain, so a zone there is legitimately "foreign" for a while by design. That is the ordering the registry imposes, not a mistake.',
1484
+ "",
1485
+ 'Example: check_dns_delegation({ domain: "wohnwagen-wuckert.de" }) \u2192 { delegation: { status: "foreign", observedNameservers: ["docks15.rzone.de","shades13.rzone.de"], expectedNameservers: ["ns1.hoststack.dev","ns2.hoststack.dev"] } }'
1486
+ ].join("\n"),
1487
+ input: {
1488
+ zone_id: z8.string().optional().describe('Zone publicId (e.g. "dnz_abc").'),
1489
+ domain: z8.string().optional().describe("Apex or subdomain \u2014 resolves to the longest-matching hosted zone.")
1490
+ },
1491
+ handler: async (args2, ctx) => {
1492
+ const teamId = await ctx.resolveTeamId();
1493
+ const zoneInput = {};
1494
+ if (args2.zone_id !== void 0) zoneInput.zone_id = args2.zone_id;
1495
+ if (args2.domain !== void 0) zoneInput.domain = args2.domain;
1496
+ const zone = await resolveZonePublicId(ctx.hoststack, teamId, zoneInput);
1497
+ const { delegation } = await ctx.hoststack.dns.checkDelegation(teamId, zone.publicId);
1498
+ const observed = delegation.observedNameservers.join(", ");
1499
+ const expected = delegation.expectedNameservers.join(" / ");
1500
+ const summary = delegation.status === "delegated" ? `${zone.domainName} is delegated to HostStack (${observed}). Queries for it reach this zone.` : delegation.status === "foreign" ? `${zone.domainName} is HOSTED here but NOT delegated here: the registry still points it at ${observed}. Records in this zone are correct but nobody is being sent to read them \u2014 public lookups return the old host. Change the nameservers at the registrar of ${zone.domainName} to ${expected}; propagation can take up to 48 hours.` : `Could not determine the delegation for ${zone.domainName} \u2014 the NS lookup returned no usable answer (SERVFAIL, timeout, or the domain is not registered). This is NOT evidence that the delegation is wrong; do not change a registrar on the strength of it. Retry in a moment.`;
1501
+ return respond({
1502
+ summary,
1503
+ data: {
1504
+ zone: { publicId: zone.publicId, domainName: zone.domainName },
1505
+ delegation
1506
+ }
1507
+ });
1508
+ }
1509
+ });
1440
1510
  defineTool({
1441
1511
  name: "list_dns_records",
1442
1512
  category: "dns",
@@ -1768,10 +1838,14 @@ defineTool({
1768
1838
  };
1769
1839
  if (args2.path_prefix !== void 0) input.pathPrefix = args2.path_prefix;
1770
1840
  const response = await ctx.hoststack.domains.add(teamId, input);
1771
- const dnsSyncWarning = response.domain.dnsSyncWarning;
1772
- const data = dnsSyncWarning ? { domain: shapeDomain(response.domain), dnsSyncWarning } : { domain: shapeDomain(response.domain) };
1841
+ const { dnsSyncWarning, delegationWarning } = response.domain;
1842
+ const data = {
1843
+ domain: shapeDomain(response.domain),
1844
+ ...dnsSyncWarning ? { dnsSyncWarning } : {},
1845
+ ...delegationWarning ? { delegationWarning } : {}
1846
+ };
1773
1847
  return respond({
1774
- summary: dnsSyncWarning ? `Added domain ${args2.hostname}, but its auto-created DNS record hasn't synced yet \u2014 ${dnsSyncWarning}` : `Added domain ${args2.hostname}. Configure DNS, then call verify_domain.`,
1848
+ summary: delegationWarning ? `Added domain ${args2.hostname}, but it CANNOT verify yet: ${delegationWarning}` : dnsSyncWarning ? `Added domain ${args2.hostname}, but its auto-created DNS record hasn't synced yet \u2014 ${dnsSyncWarning}` : `Added domain ${args2.hostname}. Configure DNS, then call verify_domain.`,
1775
1849
  data
1776
1850
  });
1777
1851
  }
@@ -1787,19 +1861,30 @@ defineTool({
1787
1861
  "Inputs:",
1788
1862
  " - domain_id: publicId of the domain (from list_domains or add_domain).",
1789
1863
  "",
1790
- "Returns: { ok: true }. Re-call list_domains to inspect the updated verified flag and SSL status.",
1864
+ "Returns: { domain, verified }. `verified` is the outcome of THIS check \u2014 the call does not merely queue one.",
1865
+ "",
1866
+ "If it fails and HostStack hosts the zone, the response may carry `delegationWarning`: the records are ours and correct, but the registry still delegates the apex to another host, so queries never reach us. That will NOT resolve by waiting or by calling this again \u2014 the nameservers have to change at the registrar. Use check_dns_delegation to see who the apex currently points at.",
1791
1867
  "",
1792
- 'Example: verify_domain({ domain_id: "dom_xyz" }) \u2192 { ok: true }'
1868
+ "It may instead carry `dnsSyncWarning`: the record HostStack creates for this hostname could not be published, usually because a record already at that name conflicts with it (a CNAME and an A cannot share a name). Also not fixable by retrying \u2014 resolve the conflict it names with list_dns_records / delete_dns_record first.",
1869
+ "",
1870
+ 'Example: verify_domain({ domain_id: "dom_xyz" }) \u2192 { verified: false, delegationWarning: "\u2026the registry still delegates wohnwagen-wuckert.de to docks15.rzone.de\u2026" }'
1793
1871
  ].join("\n"),
1794
1872
  input: {
1795
1873
  domain_id: z9.string().describe("Domain publicId.")
1796
1874
  },
1797
1875
  handler: async (args2, ctx) => {
1798
1876
  const teamId = await ctx.resolveTeamId();
1799
- await ctx.hoststack.domains.verify(teamId, args2.domain_id);
1877
+ const { domain } = await ctx.hoststack.domains.verify(teamId, args2.domain_id);
1878
+ const verified = domain.status === "active";
1879
+ const summary = verified ? `Verified ${domain.domain} \u2014 it is now active.` : domain.delegationWarning ? `${domain.domain} did NOT verify, and retrying will not help: ${domain.delegationWarning}` : domain.dnsSyncWarning ? `${domain.domain} did NOT verify, and retrying will not help: ${domain.dnsSyncWarning}` : `${domain.domain} did not verify yet (status: ${domain.status}). DNS changes can take time to propagate; check the records resolve publicly, then call this again.`;
1800
1880
  return respond({
1801
- summary: `Triggered DNS verification for ${args2.domain_id}.`,
1802
- data: { ok: true }
1881
+ summary,
1882
+ data: {
1883
+ domain: shapeDomain(domain),
1884
+ verified,
1885
+ ...domain.delegationWarning ? { delegationWarning: domain.delegationWarning } : {},
1886
+ ...domain.dnsSyncWarning ? { dnsSyncWarning: domain.dnsSyncWarning } : {}
1887
+ }
1803
1888
  });
1804
1889
  }
1805
1890
  });
@@ -2893,19 +2978,69 @@ defineTool({
2893
2978
  });
2894
2979
 
2895
2980
  // src/tools/github.ts
2981
+ import { z as z14 } from "zod";
2982
+ function shapeRepo(repo) {
2983
+ return {
2984
+ id: repo.id,
2985
+ fullName: repo.fullName,
2986
+ defaultBranch: repo.defaultBranch,
2987
+ private: repo.isPrivate,
2988
+ url: repo.htmlUrl,
2989
+ ...repo.language ? { language: repo.language } : {}
2990
+ };
2991
+ }
2992
+ defineTool({
2993
+ name: "list_github_repos",
2994
+ category: "github",
2995
+ description: [
2996
+ "Every GitHub repository this team can deploy, with the `id` create_service takes \u2014 and the `owner/name` you can pass instead of it.",
2997
+ "",
2998
+ "When to use: before create_service or create_standalone_dev_environment with a repo source. `github_repo_id` is a HostStack row id, NOT the id GitHub shows \u2014 it appears in no clone URL and no GitHub API response, so it cannot be derived and must not be guessed. (Guessing used to be survivable-looking: the create returned 200 and the mistake surfaced one step later as `Invalid git clone URL` from the deploy. It is now refused at create time.)",
2999
+ "",
3000
+ "Inputs:",
3001
+ ' - q (optional): case-insensitive substring of the full name ("owner/name"). Omit for everything.',
3002
+ "",
3003
+ "Returns: { items: [{ id, fullName, defaultBranch, private, url, language? }], total } \u2014 `total` is the count before `q` filtered it.",
3004
+ "",
3005
+ "If the repo you just pushed is missing, call sync_github_repos first: HostStack cannot see a brand-new repository until an installation re-sync runs.",
3006
+ "",
3007
+ 'Example: list_github_repos({ q: "stenshoppen" }) \u2192 { items: [{ id: 143, fullName: "acme/stenshoppen", defaultBranch: "master", private: true }], total: 45 }',
3008
+ 'Then either: create_service({ \u2026, github_repo: "acme/stenshoppen" }) \u2014 or the id, if you prefer.'
3009
+ ].join("\n"),
3010
+ input: {
3011
+ q: z14.string().min(1).max(200).optional().describe('Case-insensitive substring of "owner/name". Omit to list everything.')
3012
+ },
3013
+ handler: async (args2, ctx) => {
3014
+ const teamId = await ctx.resolveTeamId();
3015
+ const { repos } = await ctx.api.get(
3016
+ `/api/github/${teamId}/repos`
3017
+ );
3018
+ const needle = args2.q?.trim().toLowerCase();
3019
+ const matched = needle ? repos.filter((r) => r.fullName.toLowerCase().includes(needle)) : repos;
3020
+ const items = matched.map(shapeRepo).sort((a, b) => a.fullName.localeCompare(b.fullName));
3021
+ if (repos.length === 0) {
3022
+ return respond({
3023
+ summary: "No GitHub repositories are connected to this team. Install the HostStack GitHub App from the dashboard (Settings \u2192 GitHub), then call sync_github_repos.",
3024
+ data: { items, total: 0 }
3025
+ });
3026
+ }
3027
+ const summary = needle ? items.length === 0 ? `No connected repository matches "${args2.q}" (${repos.length} connected). Push it, then call sync_github_repos.` : `${items.length} of ${repos.length} connected repositories match "${args2.q}".` : `${items.length} connected repositor${items.length === 1 ? "y" : "ies"}.`;
3028
+ return respond({ summary, data: { items, total: repos.length } });
3029
+ }
3030
+ });
2896
3031
  defineTool({
2897
3032
  name: "sync_github_repos",
2898
3033
  category: "github",
2899
3034
  description: [
2900
- "Re-sync the repository list from GitHub for every connected GitHub App installation.",
3035
+ "Re-sync the repository list from GitHub for every connected GitHub App installation, and return what that now exposes.",
2901
3036
  "",
2902
- "When to use: right after pushing a brand-new repository, HostStack cannot see it until an installation re-sync runs \u2014 so create_service with a github_repo_id would fail. Call this first. (Same operation as the dashboard's refresh icon on the repo picker.)",
3037
+ "When to use: right after pushing a brand-new repository, HostStack cannot see it until an installation re-sync runs \u2014 so create_service with a github_repo_id or github_repo would fail. Call this first. (Same operation as the dashboard's refresh icon on the repo picker.) To read the list WITHOUT re-syncing, call list_github_repos.",
2903
3038
  "",
2904
3039
  "No inputs.",
2905
3040
  "",
2906
- "Returns: { installations: number, repos: [{ account, count }], totalRepos } \u2014 the accounts synced and how many repos each now exposes.",
3041
+ "Returns: { installations: number, accounts: [{ account, count }], totalRepos, repos: [{ id, fullName }] } \u2014 `repos` carries the ids create_service takes, so a sync is enough to act on; it does not have to be followed by a list.",
2907
3042
  "",
2908
- 'Example: sync_github_repos({}) \u2192 { installations: 1, repos: [{ account: "acme", count: 12 }], totalRepos: 12 }'
3043
+ 'Example: sync_github_repos({}) \u2192 { installations: 1, accounts: [{ account: "acme", count: 12 }], totalRepos: 12, repos: [{ id: 143, fullName: "acme/stenshoppen" }, \u2026] }'
2909
3044
  ].join("\n"),
2910
3045
  input: {},
2911
3046
  handler: async (_args, ctx) => {
@@ -2919,23 +3054,29 @@ defineTool({
2919
3054
  );
2920
3055
  }
2921
3056
  const perAccount = [];
2922
- let totalRepos = 0;
3057
+ const synced = [];
2923
3058
  for (const inst of installations) {
2924
3059
  const { repos } = await ctx.api.post(
2925
3060
  `/api/github/${teamId}/installations/${inst.id}/sync`
2926
3061
  );
2927
3062
  perAccount.push({ account: inst.accountLogin, count: repos.length });
2928
- totalRepos += repos.length;
3063
+ for (const repo of repos) synced.push({ id: repo.id, fullName: repo.fullName });
2929
3064
  }
3065
+ synced.sort((a, b) => a.fullName.localeCompare(b.fullName));
2930
3066
  return respond({
2931
- summary: `Synced ${totalRepos} repositories across ${installations.length} installation(s).`,
2932
- data: { installations: installations.length, repos: perAccount, totalRepos }
3067
+ summary: `Synced ${synced.length} repositories across ${installations.length} installation(s).`,
3068
+ data: {
3069
+ installations: installations.length,
3070
+ accounts: perAccount,
3071
+ totalRepos: synced.length,
3072
+ repos: synced
3073
+ }
2933
3074
  });
2934
3075
  }
2935
3076
  });
2936
3077
 
2937
3078
  // src/tools/issue-reports.ts
2938
- import { z as z14 } from "zod";
3079
+ import { z as z15 } from "zod";
2939
3080
  defineTool({
2940
3081
  name: "report_issue",
2941
3082
  category: "support",
@@ -2957,16 +3098,16 @@ defineTool({
2957
3098
  "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' } }"
2958
3099
  ].join("\n"),
2959
3100
  input: {
2960
- title: z14.string().min(1).max(300).describe("One-line summary of the fault."),
2961
- description: z14.string().min(1).max(1e4).describe("What happened, what you expected instead, and what you already ruled out."),
2962
- severity: z14.enum(["low", "normal", "high", "urgent"]).optional().describe(
3101
+ title: z15.string().min(1).max(300).describe("One-line summary of the fault."),
3102
+ description: z15.string().min(1).max(1e4).describe("What happened, what you expected instead, and what you already ruled out."),
3103
+ severity: z15.enum(["low", "normal", "high", "urgent"]).optional().describe(
2963
3104
  'Default normal. Use high/urgent only when something is DOWN or losing data \u2014 not for "this is annoying".'
2964
3105
  ),
2965
- serviceId: z14.number().int().positive().optional().describe(
3106
+ serviceId: z15.number().int().positive().optional().describe(
2966
3107
  "The affected service. Attaches the service, its latest deploy, and that deploy log tail automatically."
2967
3108
  ),
2968
- deployId: z14.number().int().positive().optional().describe("Pin a specific deploy instead of the service\u2019s most recent one."),
2969
- databaseId: z14.number().int().positive().optional().describe("The affected database.")
3109
+ deployId: z15.number().int().positive().optional().describe("Pin a specific deploy instead of the service\u2019s most recent one."),
3110
+ databaseId: z15.number().int().positive().optional().describe("The affected database.")
2970
3111
  },
2971
3112
  handler: async (args2, ctx) => {
2972
3113
  const teamId = await ctx.resolveTeamId();
@@ -3054,7 +3195,7 @@ defineTool({
3054
3195
  });
3055
3196
 
3056
3197
  // src/tools/notifications.ts
3057
- import { z as z15 } from "zod";
3198
+ import { z as z16 } from "zod";
3058
3199
  var NOTIFICATION_EVENTS = [
3059
3200
  "deploy.started",
3060
3201
  "deploy.succeeded",
@@ -3080,6 +3221,7 @@ var NOTIFICATION_EVENTS = [
3080
3221
  "error.issue_regressed",
3081
3222
  "git.auth_failed",
3082
3223
  "cron.execution_failed",
3224
+ "cron.schedule_missed",
3083
3225
  "workflow.failed",
3084
3226
  "devenv.agent.needs_input",
3085
3227
  "devenv.agent.finished",
@@ -3092,6 +3234,10 @@ var NOTIFICATION_EVENTS = [
3092
3234
  "database.restore_failed",
3093
3235
  "volume.backup_failed",
3094
3236
  "volume.backup_overdue",
3237
+ "dns.registry_status_changed",
3238
+ "dns.registry_expiring",
3239
+ "dns.registry_domain_missing",
3240
+ "dns.registry_record_changed",
3095
3241
  "domain.registrant_verification_lapsed",
3096
3242
  "service.auto_restarted",
3097
3243
  "machine.offline",
@@ -3148,10 +3294,10 @@ defineTool({
3148
3294
  "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'] })"
3149
3295
  ].join("\n"),
3150
3296
  input: {
3151
- type: z15.enum(["slack", "discord", "email"]).describe("Channel type."),
3152
- name: z15.string().min(1).max(128).describe("Human-readable label."),
3153
- webhook_url: z15.string().max(500).describe("Slack/Discord webhook URL or email address (when type=email)."),
3154
- events: z15.array(z15.enum(NOTIFICATION_EVENTS)).describe(
3297
+ type: z16.enum(["slack", "discord", "email"]).describe("Channel type."),
3298
+ name: z16.string().min(1).max(128).describe("Human-readable label."),
3299
+ webhook_url: z16.string().max(500).describe("Slack/Discord webhook URL or email address (when type=email)."),
3300
+ events: z16.array(z16.enum(NOTIFICATION_EVENTS)).describe(
3155
3301
  "List of events the channel subscribes to. Empty list = subscribe to nothing."
3156
3302
  )
3157
3303
  },
@@ -3189,10 +3335,10 @@ defineTool({
3189
3335
  "Example: update_notification_channel({ channel_id: 3, events: ['deploy.failed', 'service.restart_failed', 'git.auth_failed'] })"
3190
3336
  ].join("\n"),
3191
3337
  input: {
3192
- channel_id: z15.number().int().positive().describe("Numeric channel id from list_notification_channels."),
3193
- name: z15.string().min(1).max(128).optional().describe("New label."),
3194
- active: z15.boolean().optional().describe("false silences without deleting."),
3195
- events: z15.array(z15.enum(NOTIFICATION_EVENTS)).optional().describe("Replaces the full subscription list.")
3338
+ channel_id: z16.number().int().positive().describe("Numeric channel id from list_notification_channels."),
3339
+ name: z16.string().min(1).max(128).optional().describe("New label."),
3340
+ active: z16.boolean().optional().describe("false silences without deleting."),
3341
+ events: z16.array(z16.enum(NOTIFICATION_EVENTS)).optional().describe("Replaces the full subscription list.")
3196
3342
  },
3197
3343
  handler: async (args2, ctx) => {
3198
3344
  const teamId = await ctx.resolveTeamId();
@@ -3231,7 +3377,7 @@ defineTool({
3231
3377
  "Example: delete_notification_channel({ channel_id: 3 }) \u2192 { ok: true }"
3232
3378
  ].join("\n"),
3233
3379
  input: {
3234
- channel_id: z15.number().int().positive().describe("Numeric channel id.")
3380
+ channel_id: z16.number().int().positive().describe("Numeric channel id.")
3235
3381
  },
3236
3382
  handler: async (args2, ctx) => {
3237
3383
  const teamId = await ctx.resolveTeamId();
@@ -3258,7 +3404,7 @@ defineTool({
3258
3404
  "Example: test_notification_channel({ channel_id: 3 }) \u2192 { success: true }"
3259
3405
  ].join("\n"),
3260
3406
  input: {
3261
- channel_id: z15.number().int().positive().describe("Numeric channel id.")
3407
+ channel_id: z16.number().int().positive().describe("Numeric channel id.")
3262
3408
  },
3263
3409
  handler: async (args2, ctx) => {
3264
3410
  const teamId = await ctx.resolveTeamId();
@@ -3273,7 +3419,7 @@ defineTool({
3273
3419
  });
3274
3420
 
3275
3421
  // src/tools/projects.ts
3276
- import { z as z16 } from "zod";
3422
+ import { z as z17 } from "zod";
3277
3423
  var AVAILABLE_REGION_IDS = ["eu-central-1"];
3278
3424
  defineTool({
3279
3425
  name: "list_projects",
@@ -3314,9 +3460,9 @@ defineTool({
3314
3460
  'Example: create_project({ name: "billing-api", description: "Stripe webhooks", region: "eu-central-1" }) \u2192 { project: { id: 12, publicId: "prj_\u2026", \u2026 } }'
3315
3461
  ].join("\n"),
3316
3462
  input: {
3317
- name: z16.string().min(1).max(60).describe("Project name (1\u201360 chars)."),
3318
- description: z16.string().max(500).optional().describe("Short description (\u2264500 chars)."),
3319
- region: z16.enum(AVAILABLE_REGION_IDS).optional().describe("Region: eu-central-1 (Falkenstein) \u2014 currently the only available region.")
3463
+ name: z17.string().min(1).max(60).describe("Project name (1\u201360 chars)."),
3464
+ description: z17.string().max(500).optional().describe("Short description (\u2264500 chars)."),
3465
+ region: z17.enum(AVAILABLE_REGION_IDS).optional().describe("Region: eu-central-1 (Falkenstein) \u2014 currently the only available region.")
3320
3466
  },
3321
3467
  handler: async (args2, ctx) => {
3322
3468
  const teamId = await ctx.resolveTeamId();
@@ -3349,9 +3495,9 @@ defineTool({
3349
3495
  'Example: update_project({ project_id: "prj_abc", name: "billing-prod" }) \u2192 { project: { name: "billing-prod", \u2026 } }'
3350
3496
  ].join("\n"),
3351
3497
  input: {
3352
- project_id: z16.string().describe("Project publicId."),
3353
- name: z16.string().min(1).max(60).optional().describe("New name (1\u201360 chars)."),
3354
- description: z16.string().max(500).optional().describe("New description (\u2264500 chars).")
3498
+ project_id: z17.string().describe("Project publicId."),
3499
+ name: z17.string().min(1).max(60).optional().describe("New name (1\u201360 chars)."),
3500
+ description: z17.string().max(500).optional().describe("New description (\u2264500 chars).")
3355
3501
  },
3356
3502
  handler: async (args2, ctx) => {
3357
3503
  if (args2.name === void 0 && args2.description === void 0) {
@@ -3385,7 +3531,7 @@ defineTool({
3385
3531
  'Example: get_project({ project_id: "prj_abc" }) \u2192 { project: { id: 12, name: "billing", \u2026 } }'
3386
3532
  ].join("\n"),
3387
3533
  input: {
3388
- project_id: z16.string().describe("Project publicId (e.g. prj_abc123).")
3534
+ project_id: z17.string().describe("Project publicId (e.g. prj_abc123).")
3389
3535
  },
3390
3536
  handler: async (args2, ctx) => {
3391
3537
  const teamId = await ctx.resolveTeamId();
@@ -3397,7 +3543,7 @@ defineTool({
3397
3543
  });
3398
3544
 
3399
3545
  // src/tools/dev-tasks.ts
3400
- import { z as z17 } from "zod";
3546
+ import { z as z18 } from "zod";
3401
3547
  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.";
3402
3548
  defineTool({
3403
3549
  name: "list_dev_tasks",
@@ -3417,7 +3563,7 @@ defineTool({
3417
3563
  'Example: list_dev_tasks({ project_id: 26 }) \u2192 { items: [{ publicId: "task_\u2026", status: "idea", title: "Fix the footprint join" }] }'
3418
3564
  ].join("\n"),
3419
3565
  input: {
3420
- project_id: z17.union([z17.number().int().positive(), z17.string()]).describe('Project \u2014 publicId ("prj_\u2026") or numeric id.')
3566
+ project_id: z18.union([z18.number().int().positive(), z18.string()]).describe('Project \u2014 publicId ("prj_\u2026") or numeric id.')
3421
3567
  },
3422
3568
  handler: async (args2, ctx) => {
3423
3569
  const teamId = await ctx.resolveTeamId();
@@ -3446,7 +3592,7 @@ defineTool({
3446
3592
  '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" } }'
3447
3593
  ].join("\n"),
3448
3594
  input: {
3449
- task_id: z17.union([z17.number().int().positive(), z17.string()]).describe('Task \u2014 publicId ("task_\u2026") or numeric id.')
3595
+ task_id: z18.union([z18.number().int().positive(), z18.string()]).describe('Task \u2014 publicId ("task_\u2026") or numeric id.')
3450
3596
  },
3451
3597
  handler: async (args2, ctx) => {
3452
3598
  const teamId = await ctx.resolveTeamId();
@@ -3474,10 +3620,10 @@ defineTool({
3474
3620
  'Example: create_dev_task({ project_id: 26, service_id: 51, title: "Footprint coverage", body: "The BBRUUID join returns 17 of 49 \u2026" })'
3475
3621
  ].join("\n"),
3476
3622
  input: {
3477
- project_id: z17.union([z17.number().int().positive(), z17.string()]).describe('Project \u2014 publicId ("prj_\u2026") or numeric id.'),
3478
- title: z17.string().min(1).max(200).describe("One-line title, \u2264200 chars."),
3479
- body: z17.string().max(2e4).optional().describe("The prompt handed to the agent. Markdown, \u226420 000 chars."),
3480
- service_id: z17.union([z17.number().int().positive(), z17.string()]).optional().describe(
3623
+ project_id: z18.union([z18.number().int().positive(), z18.string()]).describe('Project \u2014 publicId ("prj_\u2026") or numeric id.'),
3624
+ title: z18.string().min(1).max(200).describe("One-line title, \u2264200 chars."),
3625
+ body: z18.string().max(2e4).optional().describe("The prompt handed to the agent. Markdown, \u226420 000 chars."),
3626
+ service_id: z18.union([z18.number().int().positive(), z18.string()]).optional().describe(
3481
3627
  'Dev box to pin it to \u2014 publicId ("svc_\u2026") or numeric id. Omit for a loose idea.'
3482
3628
  )
3483
3629
  },
@@ -3523,13 +3669,13 @@ defineTool({
3523
3669
  'Example: update_dev_task({ task_id: "task_hy1i2jdp\u2026", status: "done" }) \u2192 { data: { status: "done" } }'
3524
3670
  ].join("\n"),
3525
3671
  input: {
3526
- task_id: z17.union([z17.number().int().positive(), z17.string()]).describe('Task \u2014 publicId ("task_\u2026") or numeric id.'),
3527
- status: z17.enum(["idea", "done"]).optional().describe("The only two a person may set. The runner owns the rest of the lifecycle."),
3528
- title: z17.string().min(1).max(200).optional().describe("New title."),
3529
- body: z17.string().max(2e4).optional().describe("New prompt body."),
3530
- 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."),
3531
- provider: z17.enum(["claude", "codex", "opencode"]).nullable().optional().describe("Which agent runs this task, or null to leave it to the box."),
3532
- permission_mode: z17.enum(["acceptEdits", "bypassPermissions", "default"]).optional().describe(
3672
+ task_id: z18.union([z18.number().int().positive(), z18.string()]).describe('Task \u2014 publicId ("task_\u2026") or numeric id.'),
3673
+ status: z18.enum(["idea", "done"]).optional().describe("The only two a person may set. The runner owns the rest of the lifecycle."),
3674
+ title: z18.string().min(1).max(200).optional().describe("New title."),
3675
+ body: z18.string().max(2e4).optional().describe("New prompt body."),
3676
+ service_id: z18.union([z18.number().int().positive(), z18.string()]).nullable().optional().describe("Pin to a dev box, or null to unpin it back to a loose idea."),
3677
+ provider: z18.enum(["claude", "codex", "opencode"]).nullable().optional().describe("Which agent runs this task, or null to leave it to the box."),
3678
+ permission_mode: z18.enum(["acceptEdits", "bypassPermissions", "default"]).optional().describe(
3533
3679
  "How the agent may act. bypassPermissions asks for nothing \u2014 set it only on request."
3534
3680
  )
3535
3681
  },
@@ -3554,7 +3700,7 @@ defineTool({
3554
3700
  });
3555
3701
 
3556
3702
  // src/tools/resource-links.ts
3557
- import { z as z18 } from "zod";
3703
+ import { z as z19 } from "zod";
3558
3704
  var RESOURCE_LINK_TYPES = [
3559
3705
  "database",
3560
3706
  "object_storage",
@@ -3615,7 +3761,7 @@ defineTool({
3615
3761
  'Example: list_service_resources({ service_id: "svc_abc" }) \u2192 { items: [{ id: 7, resourceType: "database", resourceId: 42, alias: "APP_DB" }] }'
3616
3762
  ].join("\n"),
3617
3763
  input: {
3618
- service_id: z18.union([z18.number().int().positive(), z18.string()]).describe('Service \u2014 publicId ("svc_\u2026") or numeric id.')
3764
+ service_id: z19.union([z19.number().int().positive(), z19.string()]).describe('Service \u2014 publicId ("svc_\u2026") or numeric id.')
3619
3765
  },
3620
3766
  handler: async (args2, ctx) => {
3621
3767
  const teamId = await ctx.resolveTeamId();
@@ -3652,10 +3798,10 @@ defineTool({
3652
3798
  '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" } }'
3653
3799
  ].join("\n"),
3654
3800
  input: {
3655
- service_id: z18.union([z18.number().int().positive(), z18.string()]).describe('Consuming service \u2014 publicId ("svc_\u2026") or numeric id.'),
3656
- resource_type: z18.enum(RESOURCE_LINK_TYPES).describe("Kind of resource being linked."),
3657
- resource_id: z18.number().int().positive().describe("NUMERIC id of the resource (e.g. database.id) \u2014 not the publicId."),
3658
- alias: z18.string().min(1).max(48).regex(
3801
+ service_id: z19.union([z19.number().int().positive(), z19.string()]).describe('Consuming service \u2014 publicId ("svc_\u2026") or numeric id.'),
3802
+ resource_type: z19.enum(RESOURCE_LINK_TYPES).describe("Kind of resource being linked."),
3803
+ resource_id: z19.number().int().positive().describe("NUMERIC id of the resource (e.g. database.id) \u2014 not the publicId."),
3804
+ alias: z19.string().min(1).max(48).regex(
3659
3805
  /^[A-Z][A-Z0-9_]*$/,
3660
3806
  "Alias must be uppercase letters, digits and underscores, starting with a letter."
3661
3807
  ).describe('Uppercase env-var prefix, e.g. "APP_DB". Unique within the service.')
@@ -3691,8 +3837,8 @@ defineTool({
3691
3837
  'Example: unlink_resource_from_service({ service_id: "svc_abc", link_id: 7 }) \u2192 { ok: true }'
3692
3838
  ].join("\n"),
3693
3839
  input: {
3694
- service_id: z18.union([z18.number().int().positive(), z18.string()]).describe('Service \u2014 publicId ("svc_\u2026") or numeric id.'),
3695
- link_id: z18.number().int().positive().describe("Numeric linkId from list_service_resources (the link's own `id`).")
3840
+ service_id: z19.union([z19.number().int().positive(), z19.string()]).describe('Service \u2014 publicId ("svc_\u2026") or numeric id.'),
3841
+ link_id: z19.number().int().positive().describe("Numeric linkId from list_service_resources (the link's own `id`).")
3696
3842
  },
3697
3843
  handler: async (args2, ctx) => {
3698
3844
  const teamId = await ctx.resolveTeamId();
@@ -3704,7 +3850,7 @@ defineTool({
3704
3850
  });
3705
3851
 
3706
3852
  // src/tools/services.ts
3707
- import { z as z19 } from "zod";
3853
+ import { z as z20 } from "zod";
3708
3854
 
3709
3855
  // src/lib/app-templates.ts
3710
3856
  var MCP_APP_TEMPLATES = [
@@ -3956,16 +4102,16 @@ defineTool({
3956
4102
  ' - type: "web_service" | "private_service" | "worker" | "cron_job" | "static_site".',
3957
4103
  " - dev_environment: include Dev Boxes in the results (excluded by default).",
3958
4104
  "",
3959
- "Returns: { items: Service[] } \u2014 each service includes id, publicId, name, type, status, projectId, repoUrl, branch, runtime, createdAt.",
4105
+ "Returns: { items: Service[] } \u2014 each service includes id, publicId, name, type, status, projectId, branch, runtime, createdAt, and, for a source-built service, repoProvider/repoFullName/repoUrl (what it actually builds \u2014 the stored id alone names no repository).",
3960
4106
  "",
3961
4107
  'Example: list_services({ status: "failed" }) \u2192 only services that need attention.'
3962
4108
  ].join("\n"),
3963
4109
  input: {
3964
- project_id: z19.union([z19.number().int().positive(), z19.string()]).optional().describe("Project filter \u2014 numeric id or publicId."),
3965
- environment_id: z19.union([z19.number().int().positive(), z19.string()]).optional().describe("Environment filter \u2014 numeric id or publicId."),
3966
- status: z19.enum(["active", "deploying", "suspended", "failed", "not_deployed"]).optional().describe("Filter by current runtime status."),
3967
- type: z19.enum(["web_service", "private_service", "worker", "cron_job", "static_site"]).optional().describe("Filter by service type."),
3968
- dev_environment: z19.boolean().optional().describe(
4110
+ project_id: z20.union([z20.number().int().positive(), z20.string()]).optional().describe("Project filter \u2014 numeric id or publicId."),
4111
+ environment_id: z20.union([z20.number().int().positive(), z20.string()]).optional().describe("Environment filter \u2014 numeric id or publicId."),
4112
+ status: z20.enum(["active", "deploying", "suspended", "failed", "not_deployed"]).optional().describe("Filter by current runtime status."),
4113
+ type: z20.enum(["web_service", "private_service", "worker", "cron_job", "static_site"]).optional().describe("Filter by service type."),
4114
+ dev_environment: z20.boolean().optional().describe(
3969
4115
  "Include agentic Dev Boxes in the results (excluded by default; see list_dev_environments)."
3970
4116
  )
3971
4117
  },
@@ -4006,7 +4152,7 @@ defineTool({
4006
4152
  name: "create_service",
4007
4153
  category: "services",
4008
4154
  description: [
4009
- "Create a new service in a project. Source is either a connected git repo (github_repo_id) OR a pre-built docker_image \u2014 never both.",
4155
+ 'Create a new service in a project. Source is either a connected git repo (github_repo "owner/name", or github_repo_id) OR a pre-built docker_image \u2014 never both.',
4010
4156
  "",
4011
4157
  "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).",
4012
4158
  "",
@@ -4019,7 +4165,8 @@ defineTool({
4019
4165
  " - name: service name (1\u2013100 chars).",
4020
4166
  ' - type: "web_service" | "private_service" | "worker" | "cron_job" | "static_site".',
4021
4167
  " - docker_image (optional): pre-built image ref to deploy instead of building from source.",
4022
- " - github_repo_id (optional): connect a previously-linked GitHub repo by numeric id.",
4168
+ ' - github_repo (optional): connect a connected GitHub repo by name \u2014 "owner/name". Preferred over github_repo_id: it is what you actually know, and a wrong name is refused here instead of failing the deploy.',
4169
+ " - github_repo_id (optional): the same repo by its HostStack numeric id (from list_github_repos). NOT GitHub's id. Mutually exclusive with github_repo.",
4023
4170
  " - git_repo_url (optional): a PUBLIC https git URL, for a repo that is not connected through GitHub. Private repos do not work here \u2014 they need a provider connection, because this field carries no credentials and embedding them in the URL is rejected. Mutually exclusive with github_repo_id and docker_image.",
4024
4171
  ' - branch (optional): git branch (default "main").',
4025
4172
  " - install_command / build_command / start_command (optional): build/run shell commands. start_command is required for web/private services without a docker_image.",
@@ -4035,36 +4182,42 @@ defineTool({
4035
4182
  "",
4036
4183
  "Returns: { service: Service, deployId: number | null }.",
4037
4184
  "",
4038
- 'Example: create_service({ project_id: "prj_abc", name: "api", type: "web_service", github_repo_id: 42 }) \u2192 { service: { publicId: "svc_\u2026" }, deployId: 1234 }',
4185
+ 'Example: create_service({ project_id: "prj_abc", name: "api", type: "web_service", github_repo: "acme/api" }) \u2192 { service: { publicId: "svc_\u2026", repoUrl: "https://github.com/acme/api" }, deployId: 1234 }',
4186
+ "Repo not listed by list_github_repos? Call sync_github_repos \u2014 a repository pushed since the last sync is invisible until then.",
4039
4187
  '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.'
4040
4188
  ].join("\n"),
4041
4189
  input: {
4042
- project_id: z19.union([z19.number().int().positive(), z19.string()]).describe("Target project \u2014 numeric id or publicId."),
4043
- name: z19.string().min(1).max(100).describe("Service name (1\u2013100 chars)."),
4044
- type: z19.enum(SERVICE_TYPES).describe("Service type."),
4045
- docker_image: z19.string().max(500).optional().describe(
4190
+ project_id: z20.union([z20.number().int().positive(), z20.string()]).describe("Target project \u2014 numeric id or publicId."),
4191
+ name: z20.string().min(1).max(100).describe("Service name (1\u2013100 chars)."),
4192
+ type: z20.enum(SERVICE_TYPES).describe("Service type."),
4193
+ docker_image: z20.string().max(500).optional().describe(
4046
4194
  "Pre-built APPLICATION image ref. Mutually exclusive with github_repo_id. Not for databases \u2014 use create_database for postgres/redis/mysql/mariadb/mongodb."
4047
4195
  ),
4048
- github_repo_id: z19.number().int().positive().optional().describe("Linked GitHub repo numeric id. Mutually exclusive with docker_image."),
4049
- git_repo_url: z19.string().max(2048).optional().describe(
4196
+ github_repo: z20.string().min(1).max(255).optional().describe(
4197
+ 'Connected GitHub repo as "owner/name" (see list_github_repos). Resolved server-side. Mutually exclusive with github_repo_id and docker_image.'
4198
+ ),
4199
+ github_repo_id: z20.number().int().positive().optional().describe(
4200
+ "HostStack row id of a connected GitHub repo (list_github_repos) \u2014 not GitHub's own id. Prefer github_repo. Mutually exclusive with docker_image."
4201
+ ),
4202
+ git_repo_url: z20.string().max(2048).optional().describe(
4050
4203
  "Public https git URL. No embedded credentials \u2014 private repos need a provider connection. Mutually exclusive with github_repo_id and docker_image."
4051
4204
  ),
4052
- branch: z19.string().max(200).optional().describe('Git branch (default "main").'),
4053
- install_command: z19.string().max(1e3).optional().describe("Install shell command."),
4054
- build_command: z19.string().max(1e3).optional().describe("Build shell command."),
4055
- start_command: z19.string().max(1e3).optional().describe("Start shell command (required for web/private services without an image)."),
4056
- cron_schedule: z19.string().max(100).optional().describe("Cron expression \u2014 required for cron_job."),
4057
- publish_path: z19.string().max(500).optional().describe("Static-site output dir."),
4058
- runtime: z19.string().max(50).optional().describe("Runtime hint (node/bun/python/\u2026)."),
4059
- port: z19.number().int().min(1).max(65535).optional().describe(
4205
+ branch: z20.string().max(200).optional().describe('Git branch (default "main").'),
4206
+ install_command: z20.string().max(1e3).optional().describe("Install shell command."),
4207
+ build_command: z20.string().max(1e3).optional().describe("Build shell command."),
4208
+ start_command: z20.string().max(1e3).optional().describe("Start shell command (required for web/private services without an image)."),
4209
+ cron_schedule: z20.string().max(100).optional().describe("Cron expression \u2014 required for cron_job."),
4210
+ publish_path: z20.string().max(500).optional().describe("Static-site output dir."),
4211
+ runtime: z20.string().max(50).optional().describe("Runtime hint (node/bun/python/\u2026)."),
4212
+ port: z20.number().int().min(1).max(65535).optional().describe(
4060
4213
  "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."
4061
4214
  ),
4062
- template_id: z19.string().max(64).optional().describe(
4215
+ template_id: z20.string().max(64).optional().describe(
4063
4216
  "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."
4064
4217
  ),
4065
- plan: z19.enum(SERVICE_PLANS).optional().describe('Service size (default "micro").'),
4066
- environment_id: z19.union([z19.number().int().positive(), z19.string()]).optional().describe("Bind to a specific environment; defaults to Production."),
4067
- auto_deploy: z19.boolean().optional().describe("Trigger the first deploy immediately (default true)."),
4218
+ plan: z20.enum(SERVICE_PLANS).optional().describe('Service size (default "micro").'),
4219
+ environment_id: z20.union([z20.number().int().positive(), z20.string()]).optional().describe("Bind to a specific environment; defaults to Production."),
4220
+ auto_deploy: z20.boolean().optional().describe("Trigger the first deploy immediately (default true)."),
4068
4221
  machine: machineInput
4069
4222
  },
4070
4223
  handler: async (args2, ctx) => {
@@ -4079,6 +4232,7 @@ defineTool({
4079
4232
  projectId
4080
4233
  };
4081
4234
  if (args2.docker_image !== void 0) input.dockerImage = args2.docker_image;
4235
+ if (args2.github_repo !== void 0) input.githubRepo = args2.github_repo;
4082
4236
  if (args2.github_repo_id !== void 0) input.githubRepoId = args2.github_repo_id;
4083
4237
  if (args2.git_repo_url !== void 0) input.gitRepoUrl = args2.git_repo_url;
4084
4238
  if (args2.branch !== void 0) input.branch = args2.branch;
@@ -4136,18 +4290,18 @@ defineTool({
4136
4290
  'Example: create_dev_environment({ project_id: "prj_abc", name: "scratch", hoststack_api_key: "hs_live_\u2026" })'
4137
4291
  ].join("\n"),
4138
4292
  input: {
4139
- project_id: z19.union([z19.number().int().positive(), z19.string()]).describe("Target project \u2014 numeric id or publicId."),
4140
- name: z19.string().min(1).max(100).optional().describe('Service name (default "dev-environment").'),
4141
- plan: z19.enum(SERVICE_PLANS).optional().describe(
4293
+ project_id: z20.union([z20.number().int().positive(), z20.string()]).describe("Target project \u2014 numeric id or publicId."),
4294
+ name: z20.string().min(1).max(100).optional().describe('Service name (default "dev-environment").'),
4295
+ plan: z20.enum(SERVICE_PLANS).optional().describe(
4142
4296
  'Box size (default "standard" \u2014 2 GB, the OOM-safe floor; a smaller plan is clamped up to "standard").'
4143
4297
  ),
4144
- disk_gb: z19.number().int().min(10).max(10240).optional().describe("/workspace volume size in GB (default 10, min 10, max 10240)."),
4145
- hoststack_api_key: z19.string().optional().describe("Value for HOSTSTACK_API_KEY (enables the hoststack MCP in-container)."),
4146
- poststack_api_key: z19.string().optional().describe("Value for POSTSTACK_API_KEY (enables the poststack MCP in-container)."),
4147
- repo_url: z19.string().max(500).optional().describe(
4298
+ disk_gb: z20.number().int().min(10).max(10240).optional().describe("/workspace volume size in GB (default 10, min 10, max 10240)."),
4299
+ hoststack_api_key: z20.string().optional().describe("Value for HOSTSTACK_API_KEY (enables the hoststack MCP in-container)."),
4300
+ poststack_api_key: z20.string().optional().describe("Value for POSTSTACK_API_KEY (enables the poststack MCP in-container)."),
4301
+ repo_url: z20.string().max(500).optional().describe(
4148
4302
  "Clone this git URL into /workspace on first boot (HTTPS, or SSH once a key is set)."
4149
4303
  ),
4150
- branch: z19.string().max(200).optional().describe("Branch to clone (with repo_url)."),
4304
+ branch: z20.string().max(200).optional().describe("Branch to clone (with repo_url)."),
4151
4305
  machine: machineInput
4152
4306
  },
4153
4307
  handler: async (args2, ctx) => {
@@ -4264,9 +4418,9 @@ defineTool({
4264
4418
  '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.'
4265
4419
  ].join("\n"),
4266
4420
  input: {
4267
- service_id: z19.union([z19.number().int().positive(), z19.string()]).describe("Source service to debug \u2014 numeric id or publicId."),
4268
- include_database_clone: z19.boolean().optional().describe("Clone the linked database so the app runs on copied data (default true)."),
4269
- name: z19.string().min(1).max(100).optional().describe('Dev box name (default "<source>-dev").')
4421
+ service_id: z20.union([z20.number().int().positive(), z20.string()]).describe("Source service to debug \u2014 numeric id or publicId."),
4422
+ include_database_clone: z20.boolean().optional().describe("Clone the linked database so the app runs on copied data (default true)."),
4423
+ name: z20.string().min(1).max(100).optional().describe('Dev box name (default "<source>-dev").')
4270
4424
  },
4271
4425
  handler: async (args2, ctx) => {
4272
4426
  const teamId = await ctx.resolveTeamId();
@@ -4307,7 +4461,7 @@ defineTool({
4307
4461
  'Example: delete_dev_environment({ service_id: "svc_api_dev" }) \u2192 removes the dev box, its cloned database, and the /workspace volume.'
4308
4462
  ].join("\n"),
4309
4463
  input: {
4310
- service_id: z19.union([z19.number().int().positive(), z19.string()]).describe("The dev box to tear down \u2014 numeric id or publicId.")
4464
+ service_id: z20.union([z20.number().int().positive(), z20.string()]).describe("The dev box to tear down \u2014 numeric id or publicId.")
4311
4465
  },
4312
4466
  handler: async (args2, ctx) => {
4313
4467
  const teamId = await ctx.resolveTeamId();
@@ -4345,8 +4499,8 @@ defineTool({
4345
4499
  'Example: resize_dev_environment({ service_id: "svc_skyskraber_dev", size: "large" }) \u2192 bumps the box to the large tier, applied live.'
4346
4500
  ].join("\n"),
4347
4501
  input: {
4348
- service_id: z19.union([z19.number().int().positive(), z19.string()]).describe("The box to resize \u2014 numeric id or publicId."),
4349
- size: z19.enum(SERVICE_PLANS).describe(
4502
+ service_id: z20.union([z20.number().int().positive(), z20.string()]).describe("The box to resize \u2014 numeric id or publicId."),
4503
+ size: z20.enum(SERVICE_PLANS).describe(
4350
4504
  'Target size tier (service catalog size, e.g. "standard", "large", "xlarge").'
4351
4505
  )
4352
4506
  },
@@ -4483,7 +4637,7 @@ defineTool({
4483
4637
  "Inputs:",
4484
4638
  ' - name (optional): the Dev Box name. Omit it and the box is named after its source (the repo name, or "dev-box" when blank), with a numeric suffix if that name is taken \u2014 so "give me a dev box" needs no invented name.',
4485
4639
  ' - source_kind: "github_repo" (clone a connected repo \u2014 needs github_repo_id), "url" (clone any http(s) git URL \u2014 needs clone_url), or "blank" (empty box).',
4486
- " - github_repo_id (for github_repo): numeric id of a connected GitHub repo.",
4640
+ " - github_repo_id (for github_repo): HostStack row id of a connected GitHub repo \u2014 call list_github_repos for it. It is NOT the id GitHub shows, and it cannot be guessed.",
4487
4641
  " - clone_url (for url): an http(s) git clone URL.",
4488
4642
  " - branch (optional): branch to clone.",
4489
4643
  ' - databases (optional): companion services to attach \u2014 any of "postgres", "redis", "meilisearch".',
@@ -4493,24 +4647,26 @@ defineTool({
4493
4647
  "",
4494
4648
  "Returns: { service, devUrl, deployId } \u2014 deploying. Once live: open the Terminal tab, run `claude`, start the dev server on $PORT, view at https://<devUrl>. Tear down with delete_dev_environment.",
4495
4649
  "",
4496
- 'Example: create_standalone_dev_environment({ name: "app-dev", source_kind: "github_repo", github_repo_id: 42, databases: ["postgres","redis"] })'
4650
+ 'Example: list_github_repos({ q: "app" }) \u2192 id 42, then create_standalone_dev_environment({ name: "app-dev", source_kind: "github_repo", github_repo_id: 42, databases: ["postgres","redis"] })'
4497
4651
  ].join("\n"),
4498
4652
  input: {
4499
- name: z19.string().min(1).max(100).optional().describe(
4653
+ name: z20.string().min(1).max(100).optional().describe(
4500
4654
  '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.'
4501
4655
  ),
4502
- source_kind: z19.enum(["github_repo", "url", "blank"]).describe("Where the code comes from."),
4503
- github_repo_id: z19.number().int().positive().optional().describe('Connected GitHub repo id (required when source_kind="github_repo").'),
4504
- clone_url: z19.string().url().optional().describe('http(s) git clone URL (required when source_kind="url").'),
4505
- branch: z19.string().min(1).max(255).optional().describe("Branch to clone."),
4506
- databases: z19.array(z19.enum(["postgres", "redis", "meilisearch"])).optional().describe("Companion services to attach (fresh + empty)."),
4507
- plan: z19.enum(SERVICE_PLANS).optional().describe(
4656
+ source_kind: z20.enum(["github_repo", "url", "blank"]).describe("Where the code comes from."),
4657
+ github_repo_id: z20.number().int().positive().optional().describe(
4658
+ 'Connected GitHub repo id from list_github_repos (required when source_kind="github_repo").'
4659
+ ),
4660
+ clone_url: z20.string().url().optional().describe('http(s) git clone URL (required when source_kind="url").'),
4661
+ branch: z20.string().min(1).max(255).optional().describe("Branch to clone."),
4662
+ databases: z20.array(z20.enum(["postgres", "redis", "meilisearch"])).optional().describe("Companion services to attach (fresh + empty)."),
4663
+ plan: z20.enum(SERVICE_PLANS).optional().describe(
4508
4664
  'Box size (default "standard" \u2014 2 GB; a smaller plan is floored to "standard").'
4509
4665
  ),
4510
- agent_accounts: z19.array(
4511
- z19.object({
4512
- provider: z19.enum(["claude", "codex", "opencode"]),
4513
- account_id: z19.number().int().positive()
4666
+ agent_accounts: z20.array(
4667
+ z20.object({
4668
+ provider: z20.enum(["claude", "codex", "opencode"]),
4669
+ account_id: z20.number().int().positive()
4514
4670
  })
4515
4671
  ).max(3).optional().describe(
4516
4672
  "Bind saved agent logins by account id per provider. Omit to inherit the box owner's default logins automatically."
@@ -4583,12 +4739,12 @@ defineTool({
4583
4739
  "Inputs:",
4584
4740
  ' - service_id: publicId of the service (e.g. "svc_abc123").',
4585
4741
  "",
4586
- 'Returns: { service: Service, config: ServiceConfig } \u2014 service has type/status/runtime/repoUrl/branch/autoDeploy/region/plan/timestamps; config has memoryMb, cpuShares, diskSizeGb, port, protocol, healthCheckEnabled, healthCheckInterval, healthCheckTimeout, healthCheckGracePeriodSec, allowSearchIndexing, restartPolicy, deployStrategy ("rolling" | "recreate"), preDeployCommand, min/maxInstances, scale thresholds.',
4742
+ 'Returns: { service: Service, config: ServiceConfig } \u2014 service has type/status/runtime/branch/autoDeploy/region/plan/timestamps plus repoProvider/repoFullName/repoUrl for a source-built service (absent when it deploys a prebuilt image); config has memoryMb, cpuShares, diskSizeGb, port, protocol, healthCheckEnabled, healthCheckInterval, healthCheckTimeout, healthCheckGracePeriodSec, allowSearchIndexing, restartPolicy, deployStrategy ("rolling" | "recreate"), preDeployCommand, min/maxInstances, scale thresholds.',
4587
4743
  "",
4588
4744
  'Example: get_service({ service_id: "svc_abc" }) \u2192 { service: { type: "web", status: "running", \u2026 }, config: { healthCheckGracePeriodSec: 120, \u2026 } }'
4589
4745
  ].join("\n"),
4590
4746
  input: {
4591
- service_id: z19.string().describe("Service publicId (e.g. svc_abc123).")
4747
+ service_id: z20.string().describe("Service publicId (e.g. svc_abc123).")
4592
4748
  },
4593
4749
  handler: async (args2, ctx) => {
4594
4750
  const teamId = await ctx.resolveTeamId();
@@ -4620,7 +4776,7 @@ defineTool({
4620
4776
  'Example: get_service_metrics({ service_id: "svc_abc" }) \u2192 { metrics: { cpu: 0.42, memory: 0.71, \u2026 } }'
4621
4777
  ].join("\n"),
4622
4778
  input: {
4623
- service_id: z19.string().describe("Service publicId.")
4779
+ service_id: z20.string().describe("Service publicId.")
4624
4780
  },
4625
4781
  handler: async (args2, ctx) => {
4626
4782
  const teamId = await ctx.resolveTeamId();
@@ -4653,9 +4809,9 @@ defineTool({
4653
4809
  'Example: get_service_metrics_history({ service_id: "svc_abc", from: "-1h" }) \u2192 ~120 points for the last hour.'
4654
4810
  ].join("\n"),
4655
4811
  input: {
4656
- service_id: z19.string().describe("Service publicId."),
4657
- from: z19.string().optional().describe('ISO-8601 lower bound or relative offset (e.g. "-1h", "-2d").'),
4658
- to: z19.string().optional().describe("ISO-8601 upper bound; defaults to now.")
4812
+ service_id: z20.string().describe("Service publicId."),
4813
+ from: z20.string().optional().describe('ISO-8601 lower bound or relative offset (e.g. "-1h", "-2d").'),
4814
+ to: z20.string().optional().describe("ISO-8601 upper bound; defaults to now.")
4659
4815
  },
4660
4816
  handler: async (args2, ctx) => {
4661
4817
  const teamId = await ctx.resolveTeamId();
@@ -4699,8 +4855,8 @@ defineTool({
4699
4855
  'Example: update_service({ service_id: "svc_abc", name: "api-prod" }) \u2192 { service: { name: "api-prod", \u2026 } }'
4700
4856
  ].join("\n"),
4701
4857
  input: {
4702
- service_id: z19.string().describe("Service publicId."),
4703
- name: z19.string().min(1).max(60).describe("New service name (1\u201360 chars).")
4858
+ service_id: z20.string().describe("Service publicId."),
4859
+ name: z20.string().min(1).max(60).describe("New service name (1\u201360 chars).")
4704
4860
  },
4705
4861
  handler: async (args2, ctx) => {
4706
4862
  const teamId = await ctx.resolveTeamId();
@@ -4757,54 +4913,54 @@ defineTool({
4757
4913
  'Example: update_service_config({ service_id: "svc_abc", health_check_grace_period_sec: 180 }) \u2192 { config: { healthCheckGracePeriodSec: 180, \u2026 } }'
4758
4914
  ].join("\n"),
4759
4915
  input: {
4760
- service_id: z19.string().describe("Service publicId."),
4761
- install_command: z19.string().nullable().optional().describe("Install shell command. Null clears."),
4762
- build_command: z19.string().nullable().optional().describe("Build shell command. Null clears."),
4763
- start_command: z19.string().nullable().optional().describe("Start shell command. Null clears."),
4764
- branch: z19.string().optional().describe("Git branch to track."),
4765
- root_directory: z19.string().optional().describe("Build context root."),
4766
- dockerfile_path: z19.string().nullable().optional().describe("Path to Dockerfile relative to root. Null clears."),
4767
- auto_deploy: z19.boolean().optional().describe("Auto-deploy on push."),
4768
- health_check_path: z19.string().nullable().optional().describe('HTTP health-check path (e.g. "/health"). Null = TCP-only check.'),
4769
- health_check_enabled: z19.boolean().optional().describe("Toggle health checking on/off."),
4770
- allow_search_indexing: z19.boolean().optional().describe(
4916
+ service_id: z20.string().describe("Service publicId."),
4917
+ install_command: z20.string().nullable().optional().describe("Install shell command. Null clears."),
4918
+ build_command: z20.string().nullable().optional().describe("Build shell command. Null clears."),
4919
+ start_command: z20.string().nullable().optional().describe("Start shell command. Null clears."),
4920
+ branch: z20.string().optional().describe("Git branch to track."),
4921
+ root_directory: z20.string().optional().describe("Build context root."),
4922
+ dockerfile_path: z20.string().nullable().optional().describe("Path to Dockerfile relative to root. Null clears."),
4923
+ auto_deploy: z20.boolean().optional().describe("Auto-deploy on push."),
4924
+ health_check_path: z20.string().nullable().optional().describe('HTTP health-check path (e.g. "/health"). Null = TCP-only check.'),
4925
+ health_check_enabled: z20.boolean().optional().describe("Toggle health checking on/off."),
4926
+ allow_search_indexing: z20.boolean().optional().describe(
4771
4927
  "Let search engines index the free *.hoststack.dev platform URL (off by default). Custom domains are always indexable. Applies on the next deploy."
4772
4928
  ),
4773
- health_check_interval: z19.number().int().min(5).max(300).optional().describe("How often the check runs, in seconds (5\u2013300)."),
4774
- health_check_timeout: z19.number().int().min(1).max(60).optional().describe("Single-attempt timeout in seconds (1\u201360)."),
4775
- health_check_grace_period_sec: z19.number().int().min(1).max(1800).optional().describe(
4929
+ health_check_interval: z20.number().int().min(5).max(300).optional().describe("How often the check runs, in seconds (5\u2013300)."),
4930
+ health_check_timeout: z20.number().int().min(1).max(60).optional().describe("Single-attempt timeout in seconds (1\u201360)."),
4931
+ health_check_grace_period_sec: z20.number().int().min(1).max(1800).optional().describe(
4776
4932
  "Startup grace period in seconds (1\u20131800). Raise this if the app needs more time to boot before health checks start counting failures."
4777
4933
  ),
4778
- memory_mb: z19.number().int().min(128).max(16384).optional().describe("Container memory cap in MB (128\u201316384)."),
4779
- cpu_shares: z19.number().int().min(128).max(4096).optional().describe("Relative CPU weight (128\u20134096)."),
4780
- disk_size_gb: z19.number().int().min(1).max(100).optional().describe("Ephemeral disk size in GB (1\u2013100)."),
4781
- port: z19.number().int().min(1).max(65535).optional().describe("Container port the platform forwards traffic to."),
4782
- protocol: z19.enum(["http", "tcp"]).optional().describe("Traffic protocol."),
4783
- restart_policy: z19.enum(["always", "on-failure", "no"]).optional().describe("Docker restart policy."),
4784
- deploy_strategy: z19.enum(["rolling", "recreate"]).optional().describe(
4934
+ memory_mb: z20.number().int().min(128).max(16384).optional().describe("Container memory cap in MB (128\u201316384)."),
4935
+ cpu_shares: z20.number().int().min(128).max(4096).optional().describe("Relative CPU weight (128\u20134096)."),
4936
+ disk_size_gb: z20.number().int().min(1).max(100).optional().describe("Ephemeral disk size in GB (1\u2013100)."),
4937
+ port: z20.number().int().min(1).max(65535).optional().describe("Container port the platform forwards traffic to."),
4938
+ protocol: z20.enum(["http", "tcp"]).optional().describe("Traffic protocol."),
4939
+ restart_policy: z20.enum(["always", "on-failure", "no"]).optional().describe("Docker restart policy."),
4940
+ deploy_strategy: z20.enum(["rolling", "recreate"]).optional().describe(
4785
4941
  '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.'
4786
4942
  ),
4787
- pre_deploy_command: z19.string().optional().describe("Shell command run before the new release accepts traffic."),
4788
- instance_count: z19.number().int().positive().max(50).optional().describe("Pin min and max instances to this value (1\u201350)."),
4789
- min_instances: z19.number().int().min(0).max(50).optional().describe("Autoscale lower bound. Use with max_instances for a range."),
4790
- max_instances: z19.number().int().min(1).max(50).optional().describe("Autoscale upper bound. Use with min_instances for a range."),
4791
- scale_cpu_threshold: z19.number().int().min(10).max(100).optional().describe("Autoscale CPU trigger percentage (10\u2013100)."),
4792
- scale_memory_threshold: z19.number().int().min(10).max(100).optional().describe("Autoscale memory trigger percentage (10\u2013100)."),
4793
- scale_requests_per_sec_threshold: z19.number().int().min(1).max(1e4).nullable().optional().describe("Autoscale request-rate trigger (1\u201310000 req/s). null stops using it."),
4794
- build_timeout_seconds: z19.number().int().min(60).max(1800).nullable().optional().describe("Kill a build that runs longer than this (60\u20131800s). null = default."),
4795
- plan: z19.enum(SERVICE_PLANS).optional().describe(
4943
+ pre_deploy_command: z20.string().optional().describe("Shell command run before the new release accepts traffic."),
4944
+ instance_count: z20.number().int().positive().max(50).optional().describe("Pin min and max instances to this value (1\u201350)."),
4945
+ min_instances: z20.number().int().min(0).max(50).optional().describe("Autoscale lower bound. Use with max_instances for a range."),
4946
+ max_instances: z20.number().int().min(1).max(50).optional().describe("Autoscale upper bound. Use with min_instances for a range."),
4947
+ scale_cpu_threshold: z20.number().int().min(10).max(100).optional().describe("Autoscale CPU trigger percentage (10\u2013100)."),
4948
+ scale_memory_threshold: z20.number().int().min(10).max(100).optional().describe("Autoscale memory trigger percentage (10\u2013100)."),
4949
+ scale_requests_per_sec_threshold: z20.number().int().min(1).max(1e4).nullable().optional().describe("Autoscale request-rate trigger (1\u201310000 req/s). null stops using it."),
4950
+ build_timeout_seconds: z20.number().int().min(60).max(1800).nullable().optional().describe("Kill a build that runs longer than this (60\u20131800s). null = default."),
4951
+ plan: z20.enum(SERVICE_PLANS).optional().describe(
4796
4952
  "Move to a different size tier. memory_mb and cpu_shares are clamped to the tier, so this is what raises the ceiling. Changes cost."
4797
4953
  ),
4798
- docker_image: z19.string().max(500).nullable().optional().describe(
4954
+ docker_image: z20.string().max(500).nullable().optional().describe(
4799
4955
  "Repoint an image-backed service. Applies on the next deploy. null clears it."
4800
4956
  ),
4801
- cron_schedule: z19.string().max(100).nullable().optional().describe("Cron expression for a cron_job service. null clears it."),
4802
- publish_path: z19.string().max(500).nullable().optional().describe('Static-site output directory, e.g. "dist". null clears it.'),
4803
- runtime: z19.string().max(50).optional().describe("Runtime hint (node/bun/python/\u2026). Normally auto-detected."),
4804
- log_filter_rules: z19.array(
4805
- z19.object({
4806
- pattern: z19.string().min(1).max(200),
4807
- action: z19.enum(["drop", "downgrade"])
4957
+ cron_schedule: z20.string().max(100).nullable().optional().describe("Cron expression for a cron_job service. null clears it."),
4958
+ publish_path: z20.string().max(500).nullable().optional().describe('Static-site output directory, e.g. "dist". null clears it.'),
4959
+ runtime: z20.string().max(50).optional().describe("Runtime hint (node/bun/python/\u2026). Normally auto-detected."),
4960
+ log_filter_rules: z20.array(
4961
+ z20.object({
4962
+ pattern: z20.string().min(1).max(200),
4963
+ action: z20.enum(["drop", "downgrade"])
4808
4964
  })
4809
4965
  ).max(50).optional().describe(
4810
4966
  "Runtime-log filter rules. Empty array [] clears all rules. Each pattern is case-insensitive substring match against the message."
@@ -4912,7 +5068,7 @@ defineTool({
4912
5068
  'Example: suspend_service({ service_id: "svc_dev" }) \u2192 { ok: true }'
4913
5069
  ].join("\n"),
4914
5070
  input: {
4915
- service_id: z19.string().describe("Service publicId.")
5071
+ service_id: z20.string().describe("Service publicId.")
4916
5072
  },
4917
5073
  handler: async (args2, ctx) => {
4918
5074
  const teamId = await ctx.resolveTeamId();
@@ -4936,7 +5092,7 @@ defineTool({
4936
5092
  'Example: resume_service({ service_id: "svc_dev" }) \u2192 { ok: true }'
4937
5093
  ].join("\n"),
4938
5094
  input: {
4939
- service_id: z19.string().describe("Service publicId.")
5095
+ service_id: z20.string().describe("Service publicId.")
4940
5096
  },
4941
5097
  handler: async (args2, ctx) => {
4942
5098
  const teamId = await ctx.resolveTeamId();
@@ -4962,7 +5118,7 @@ defineTool({
4962
5118
  'Example: delete_service({ service_id: "svc_abandoned" }) \u2192 { ok: true }'
4963
5119
  ].join("\n"),
4964
5120
  input: {
4965
- service_id: z19.string().describe("Service publicId.")
5121
+ service_id: z20.string().describe("Service publicId.")
4966
5122
  },
4967
5123
  handler: async (args2, ctx) => {
4968
5124
  const teamId = await ctx.resolveTeamId();
@@ -5001,16 +5157,16 @@ defineTool({
5001
5157
  ' - Just count error lines without fetching them: get_service_logs({ service_id: "svc_abc", level: "error", since: "-5m", count_only: true }) \u2192 { count: 47 }'
5002
5158
  ].join("\n"),
5003
5159
  input: {
5004
- service_id: z19.string().describe("Service publicId."),
5005
- lines: z19.number().int().positive().max(1e3).optional().describe("Tail size; default 200, hard cap 1000."),
5006
- since: z19.string().optional().describe('ISO-8601 timestamp or relative offset (e.g. "-5m", "-1h").'),
5007
- until: z19.string().optional().describe("ISO-8601 timestamp or relative offset upper bound."),
5008
- stream: z19.enum(["stdout", "stderr"]).optional().describe("Restrict to one stream."),
5009
- level: z19.enum(["stdout", "stderr", "trace", "debug", "info", "warn", "error", "fatal"]).optional().describe(
5160
+ service_id: z20.string().describe("Service publicId."),
5161
+ lines: z20.number().int().positive().max(1e3).optional().describe("Tail size; default 200, hard cap 1000."),
5162
+ since: z20.string().optional().describe('ISO-8601 timestamp or relative offset (e.g. "-5m", "-1h").'),
5163
+ until: z20.string().optional().describe("ISO-8601 timestamp or relative offset upper bound."),
5164
+ stream: z20.enum(["stdout", "stderr"]).optional().describe("Restrict to one stream."),
5165
+ level: z20.enum(["stdout", "stderr", "trace", "debug", "info", "warn", "error", "fatal"]).optional().describe(
5010
5166
  "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)."
5011
5167
  ),
5012
- search: z19.string().max(100).optional().describe("Case-insensitive substring filter."),
5013
- count_only: z19.boolean().optional().describe("When true, return only { count } \u2014 skips the log payload.")
5168
+ search: z20.string().max(100).optional().describe("Case-insensitive substring filter."),
5169
+ count_only: z20.boolean().optional().describe("When true, return only { count } \u2014 skips the log payload.")
5014
5170
  },
5015
5171
  handler: async (args2, ctx) => {
5016
5172
  const teamId = await ctx.resolveTeamId();
@@ -5059,14 +5215,14 @@ defineTool({
5059
5215
  '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 } } }.'
5060
5216
  ].join("\n"),
5061
5217
  input: {
5062
- service_ids: z19.array(z19.string()).min(1).max(10).describe("Service publicIds (1\u201310). Hard cap 10 to bound parallel work."),
5063
- lines_per_service: z19.number().int().positive().max(500).optional().describe("Tail size per service; default 100, hard cap 500."),
5064
- since: z19.string().optional().describe('ISO-8601 timestamp or relative offset (e.g. "-5m", "-1h").'),
5065
- until: z19.string().optional().describe("ISO-8601 timestamp or relative offset upper bound."),
5066
- stream: z19.enum(["stdout", "stderr"]).optional().describe("Restrict to one stream."),
5067
- level: z19.enum(["stdout", "stderr", "trace", "debug", "info", "warn", "error", "fatal"]).optional().describe("Structured log level filter (same as get_service_logs)."),
5068
- search: z19.string().max(100).optional().describe("Case-insensitive substring filter."),
5069
- count_only: z19.boolean().optional().describe("When true, return only counts per service \u2014 skips the log payload.")
5218
+ service_ids: z20.array(z20.string()).min(1).max(10).describe("Service publicIds (1\u201310). Hard cap 10 to bound parallel work."),
5219
+ lines_per_service: z20.number().int().positive().max(500).optional().describe("Tail size per service; default 100, hard cap 500."),
5220
+ since: z20.string().optional().describe('ISO-8601 timestamp or relative offset (e.g. "-5m", "-1h").'),
5221
+ until: z20.string().optional().describe("ISO-8601 timestamp or relative offset upper bound."),
5222
+ stream: z20.enum(["stdout", "stderr"]).optional().describe("Restrict to one stream."),
5223
+ level: z20.enum(["stdout", "stderr", "trace", "debug", "info", "warn", "error", "fatal"]).optional().describe("Structured log level filter (same as get_service_logs)."),
5224
+ search: z20.string().max(100).optional().describe("Case-insensitive substring filter."),
5225
+ count_only: z20.boolean().optional().describe("When true, return only counts per service \u2014 skips the log payload.")
5070
5226
  },
5071
5227
  handler: async (args2, ctx) => {
5072
5228
  const teamId = await ctx.resolveTeamId();
@@ -5112,7 +5268,7 @@ defineTool({
5112
5268
  });
5113
5269
 
5114
5270
  // src/tools/uptime.ts
5115
- import { z as z20 } from "zod";
5271
+ import { z as z21 } from "zod";
5116
5272
  var STATUS_MEANING = [
5117
5273
  "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."
5118
5274
  ].join("\n");
@@ -5134,7 +5290,7 @@ defineTool({
5134
5290
  "",
5135
5291
  "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', lastTargetUrl: 'https://grundfast.dk/healthz' } }."
5136
5292
  ].join("\n"),
5137
- input: { serviceId: z20.number().int().positive() },
5293
+ input: { serviceId: z21.number().int().positive() },
5138
5294
  handler: async (args2, ctx) => {
5139
5295
  const teamId = await ctx.resolveTeamId();
5140
5296
  const response = await ctx.api.get(
@@ -5180,14 +5336,14 @@ defineTool({
5180
5336
  "Example: set_uptime_check({ serviceId: 48, path: '/healthz', intervalSeconds: 60, failureThreshold: 3 }) \u2192 { check: { status: 'unknown', \u2026 } }."
5181
5337
  ].join("\n"),
5182
5338
  input: {
5183
- serviceId: z20.number().int().positive(),
5184
- enabled: z20.boolean().optional(),
5185
- path: z20.string().max(500).optional().describe('Must start with /. Default "/".'),
5186
- method: z20.enum(["GET", "HEAD"]).optional(),
5187
- expectedStatus: z20.number().int().min(100).max(599).optional(),
5188
- timeoutMs: z20.number().int().min(1e3).max(6e4).optional(),
5189
- intervalSeconds: z20.number().int().min(30).max(3600).optional(),
5190
- failureThreshold: z20.number().int().min(1).max(10).optional()
5339
+ serviceId: z21.number().int().positive(),
5340
+ enabled: z21.boolean().optional(),
5341
+ path: z21.string().max(500).optional().describe('Must start with /. Default "/".'),
5342
+ method: z21.enum(["GET", "HEAD"]).optional(),
5343
+ expectedStatus: z21.number().int().min(100).max(599).optional(),
5344
+ timeoutMs: z21.number().int().min(1e3).max(6e4).optional(),
5345
+ intervalSeconds: z21.number().int().min(30).max(3600).optional(),
5346
+ failureThreshold: z21.number().int().min(1).max(10).optional()
5191
5347
  },
5192
5348
  handler: async (args2, ctx) => {
5193
5349
  const teamId = await ctx.resolveTeamId();
@@ -5218,7 +5374,7 @@ defineTool({
5218
5374
  "",
5219
5375
  "Example: delete_uptime_check({ serviceId: 48 }) \u2192 { success: true }."
5220
5376
  ].join("\n"),
5221
- input: { serviceId: z20.number().int().positive() },
5377
+ input: { serviceId: z21.number().int().positive() },
5222
5378
  handler: async (args2, ctx) => {
5223
5379
  const teamId = await ctx.resolveTeamId();
5224
5380
  await ctx.api.delete(`/api/services/${teamId}/${args2.serviceId}/uptime-check`);
@@ -5227,7 +5383,7 @@ defineTool({
5227
5383
  });
5228
5384
 
5229
5385
  // src/tools/volumes.ts
5230
- import { z as z21 } from "zod";
5386
+ import { z as z22 } from "zod";
5231
5387
  var MIN_VOLUME_SIZE_GB = 10;
5232
5388
  defineTool({
5233
5389
  name: "list_volumes",
@@ -5240,12 +5396,14 @@ defineTool({
5240
5396
  "Inputs:",
5241
5397
  " - service_id: publicId of the service.",
5242
5398
  "",
5243
- "Returns: { items: Volume[] } \u2014 each entry has id, publicId, name, mountPath, sizeGb, status (pending|active|deleting), createdAt, updatedAt.",
5399
+ "Returns: { items: Volume[] } \u2014 each entry has id, publicId, name, mountPath, sizeGb, status (pending|active|deleting), backupEnabled, createdAt, updatedAt.",
5400
+ "",
5401
+ "`backupEnabled` says backups are being TAKEN, not that any exist. Call list_volume_backups to see which archives are actually restorable \u2014 a volume can be enabled and have nothing behind it (nothing has completed yet, or the host has no upload grant and is writing tars onto the very disk it is backing up, which is not a copy of anything). Turn it on with update_volume({ backup_enabled: true }).",
5244
5402
  "",
5245
- 'Example: list_volumes({ service_id: "svc_abc" }) \u2192 { items: [{ name: "data", mountPath: "/var/data", sizeGb: 10, status: "active" }] }'
5403
+ 'Example: list_volumes({ service_id: "svc_abc" }) \u2192 { items: [{ name: "data", mountPath: "/var/data", sizeGb: 10, status: "active", backupEnabled: false }] }'
5246
5404
  ].join("\n"),
5247
5405
  input: {
5248
- service_id: z21.string().describe("Service publicId (e.g. svc_abc123).")
5406
+ service_id: z22.string().describe("Service publicId (e.g. svc_abc123).")
5249
5407
  },
5250
5408
  handler: async (args2, ctx) => {
5251
5409
  const teamId = await ctx.resolveTeamId();
@@ -5274,15 +5432,15 @@ defineTool({
5274
5432
  '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" } }'
5275
5433
  ].join("\n"),
5276
5434
  input: {
5277
- service_id: z21.string().describe("Service publicId."),
5278
- name: z21.string().min(1).max(64).regex(/^[a-z0-9-]+$/).describe("Volume name (lowercase alphanumeric + hyphens)."),
5279
- mount_path: z21.string().startsWith("/").max(500).describe("In-container mount path (absolute)."),
5435
+ service_id: z22.string().describe("Service publicId."),
5436
+ name: z22.string().min(1).max(64).regex(/^[a-z0-9-]+$/).describe("Volume name (lowercase alphanumeric + hyphens)."),
5437
+ mount_path: z22.string().startsWith("/").max(500).describe("In-container mount path (absolute)."),
5280
5438
  // 10 GB is the real floor: the block-storage backend rejects anything
5281
5439
  // smaller. Advertising 1 GB here (and defaulting to it) meant taking the
5282
5440
  // defaults produced a volume that provisioned with `Hetzner API error:
5283
5441
  // 422` on the NEXT deploy, with nothing tying the failure back to the
5284
5442
  // size. Reject it at the call instead.
5285
- size_gb: z21.number().int().min(MIN_VOLUME_SIZE_GB).max(100).optional().describe(
5443
+ size_gb: z22.number().int().min(MIN_VOLUME_SIZE_GB).max(100).optional().describe(
5286
5444
  `Disk size in GB (minimum ${MIN_VOLUME_SIZE_GB}, default ${MIN_VOLUME_SIZE_GB}, max 100 via MCP).`
5287
5445
  )
5288
5446
  },
@@ -5314,18 +5472,20 @@ defineTool({
5314
5472
  " - volume_id: publicId of the volume to update.",
5315
5473
  " - mount_path (optional): new in-container mount path.",
5316
5474
  " - size_gb (optional): new size in GB (must be \u2265 current).",
5317
- " - backup_enabled (optional): whether this volume is backed up. Turning it off stops future backups; it does not delete the ones already taken.",
5475
+ " - backup_enabled (optional): whether this volume is backed up nightly. Turning it off stops future backups; it does not delete the ones already taken. Verify with list_volume_backups \u2014 enabling is not the same as having a backup.",
5476
+ "",
5477
+ "IMPORTANT about what a volume backup is: a block-level tar of a LIVE filesystem, i.e. CRASH CONSISTENT, not application consistent. Nothing is quiesced. For a container running its own database (WordPress + MariaDB in one box, Postgres on a disk, SQLite under write), the archive captures whatever was on disk mid-write \u2014 the same state the database would face after a power cut. Usually recoverable, occasionally not. Recommend this as the disaster fallback and a scheduled dump as the actual backup; do not present it as a substitute for one.",
5318
5478
  "",
5319
5479
  "Returns: { volume: Volume } \u2014 the updated record.",
5320
5480
  "",
5321
5481
  'Example: update_volume({ service_id: "svc_abc", volume_id: "vol_xyz", size_gb: 20 }) \u2192 { volume: { sizeGb: 20, \u2026 } }'
5322
5482
  ].join("\n"),
5323
5483
  input: {
5324
- service_id: z21.string().describe("Service publicId."),
5325
- volume_id: z21.string().describe("Volume publicId (e.g. vol_\u2026)."),
5326
- mount_path: z21.string().startsWith("/").max(500).optional().describe("New mount path."),
5327
- 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).`),
5328
- backup_enabled: z21.boolean().optional().describe("Whether to keep backing this volume up. Past backups are kept either way.")
5484
+ service_id: z22.string().describe("Service publicId."),
5485
+ volume_id: z22.string().describe("Volume publicId (e.g. vol_\u2026)."),
5486
+ mount_path: z22.string().startsWith("/").max(500).optional().describe("New mount path."),
5487
+ size_gb: z22.number().int().min(MIN_VOLUME_SIZE_GB).max(100).optional().describe(`New size in GB (minimum ${MIN_VOLUME_SIZE_GB}, grow-only).`),
5488
+ backup_enabled: z22.boolean().optional().describe("Whether to keep backing this volume up. Past backups are kept either way.")
5329
5489
  },
5330
5490
  handler: async (args2, ctx) => {
5331
5491
  const teamId = await ctx.resolveTeamId();
@@ -5347,6 +5507,78 @@ defineTool({
5347
5507
  return respond({ summary: `Updated ${fields} on volume ${args2.volume_id}.`, data });
5348
5508
  }
5349
5509
  });
5510
+ defineTool({
5511
+ name: "list_volume_backups",
5512
+ category: "volumes",
5513
+ description: [
5514
+ "List the archives a volume can be restored from, newest first.",
5515
+ "",
5516
+ 'WHY THIS MATTERS: `backupEnabled` on a volume says backups are being TAKEN. It does not say any exist. A volume can be enabled and have nothing behind it \u2014 nothing has completed a cycle yet, or the host has no upload grant and is writing its tar onto the very disk it is backing up (that file is not a copy of anything and is deliberately NOT listed here). This tool is how you tell "protected" from "believed to be protected". Check it whenever a volume holds the only copy of something.',
5517
+ "",
5518
+ "When to use: before a risky migration or deploy, when someone asks what the recovery position is, or to pick an archive to hand to restore_volume.",
5519
+ "",
5520
+ "Inputs:",
5521
+ " - service_id: publicId of the service.",
5522
+ " - volume_id: publicId of the volume (vol_\u2026).",
5523
+ "",
5524
+ "Returns: { items: RestorePoint[] } \u2014 id (pass to restore_volume), archiveName, sizeBytes (may be null on older agents), createdAt, s3Url. Only the most recent few are kept: the host prunes older archives out of the bucket on its own schedule.",
5525
+ "",
5526
+ 'Example: list_volume_backups({ service_id: "svc_abc", volume_id: "vol_xyz" }) \u2192 { items: [{ id: 41, archiveName: "backup-20260911-0300.tar.gz", sizeBytes: 184532992, createdAt: "2026-09-11T03:00:11Z" }] }'
5527
+ ].join("\n"),
5528
+ input: {
5529
+ service_id: z22.string().describe("Service publicId."),
5530
+ volume_id: z22.string().describe("Volume publicId (e.g. vol_\u2026).")
5531
+ },
5532
+ handler: async (args2, ctx) => {
5533
+ const teamId = await ctx.resolveTeamId();
5534
+ const response = await ctx.hoststack.volumes.listRestorePoints(
5535
+ teamId,
5536
+ args2.service_id,
5537
+ args2.volume_id
5538
+ );
5539
+ const data = shapeList(response, "restorePoints", shape);
5540
+ const newest = response.restorePoints[0];
5541
+ const summary = data.items.length === 0 ? `Volume ${args2.volume_id} has NO restore points. If backupEnabled is true, either no backup has completed yet or the host cannot upload off-site \u2014 either way there is currently nothing to restore from.` : `Volume ${args2.volume_id} has ${data.items.length} restore point${data.items.length === 1 ? "" : "s"}; newest ${newest?.archiveName} from ${newest?.createdAt}.`;
5542
+ return respond({ summary, data });
5543
+ }
5544
+ });
5545
+ defineTool({
5546
+ name: "restore_volume",
5547
+ category: "volumes",
5548
+ description: [
5549
+ "Unpack one of a volume's archives back over its contents.",
5550
+ "",
5551
+ 'DESTRUCTIVE AND NOT REVERSIBLE. Everything on the disk now is replaced by what was on it when the archive was taken. Nothing snapshots the pre-restore state first, so a restore to the wrong point loses the data you had. CONFIRM WITH THE USER \u2014 naming the archive and its timestamp \u2014 before calling this. Never call it to "check whether a backup works".',
5552
+ "",
5553
+ "What the archive is: a block-level tar of a filesystem that was LIVE when it was taken \u2014 crash consistent, not application consistent. A database inside it will come back the way it would after a power cut, and may need its own recovery on first start. Stop the service before restoring if you can.",
5554
+ "",
5555
+ "When to use: the user explicitly asks to roll a volume back to an earlier state, or to recover after data loss.",
5556
+ "",
5557
+ "Inputs:",
5558
+ " - service_id: publicId of the service.",
5559
+ " - volume_id: publicId of the volume.",
5560
+ " - backup_id: the numeric id of a restore point from list_volume_backups. Call that FIRST and show the user the choice \u2014 never guess an id.",
5561
+ "",
5562
+ "Returns: { ok: true } once the host has accepted the restore. The unpack itself runs on the host; re-check the service afterwards.",
5563
+ "",
5564
+ 'Example: restore_volume({ service_id: "svc_abc", volume_id: "vol_xyz", backup_id: 41 })'
5565
+ ].join("\n"),
5566
+ input: {
5567
+ service_id: z22.string().describe("Service publicId."),
5568
+ volume_id: z22.string().describe("Volume publicId."),
5569
+ backup_id: z22.number().int().positive().describe("Restore-point id from list_volume_backups. Confirm with the user first.")
5570
+ },
5571
+ handler: async (args2, ctx) => {
5572
+ const teamId = await ctx.resolveTeamId();
5573
+ await ctx.hoststack.volumes.restore(teamId, args2.service_id, args2.volume_id, {
5574
+ backupId: args2.backup_id
5575
+ });
5576
+ return respond({
5577
+ summary: `Restore of volume ${args2.volume_id} from backup ${args2.backup_id} accepted \u2014 the host is unpacking the archive over the volume's contents. Check the service once it finishes; a database on this volume may run its own crash recovery on first start.`,
5578
+ data: { ok: true }
5579
+ });
5580
+ }
5581
+ });
5350
5582
  defineTool({
5351
5583
  name: "delete_volume",
5352
5584
  category: "volumes",
@@ -5364,8 +5596,8 @@ defineTool({
5364
5596
  'Example: delete_volume({ service_id: "svc_abc", volume_id: "vol_xyz" }) \u2192 { ok: true }'
5365
5597
  ].join("\n"),
5366
5598
  input: {
5367
- service_id: z21.string().describe("Service publicId."),
5368
- volume_id: z21.string().describe("Volume publicId.")
5599
+ service_id: z22.string().describe("Service publicId."),
5600
+ volume_id: z22.string().describe("Volume publicId.")
5369
5601
  },
5370
5602
  handler: async (args2, ctx) => {
5371
5603
  const teamId = await ctx.resolveTeamId();