Agentic Workforce ME Developer PortalDocs 1.1 · Widget 0.1.0

Backend integration

Provisioning as code

The setup-hive pattern: idempotent creation of MCP servers, connections, grants, policies, workflows, triggers and webhooks from a script; secrets layout.

Everything on the Agentic Workforce ME side of an integration — the MCP server registration, its credential, the agent, tool grants, approval policies, the workflow, the trigger, the service’s API key and the webhook subscription — can be created through the API. The demo portals therefore never click through the console: each ships a scripts/setup-hive.ts that provisions its tenant from a fresh state and is safe to re-run. Do the same and your dev, staging and production tenants stay identical, reviewable and reproducible.

The idiom: find, create, converge

The platform has no “apply” endpoint, so the script implements convergence itself: look up each resource by a stable natural key (slug for agents, workflows and MCP servers; name for policies, triggers and keys; URL for webhook endpoints), create it when missing, patch it when drifted, and never create a duplicate.

TypeScript
// The one idiom every step uses: look up by a stable natural key (slug, name, url),
// create when missing, converge when drifted, never duplicate.
async function ensureMcpServer() {
  const { items } = await rest('GET', '/v1/mcp-servers?limit=100');
  const found = items.find((s) => s.slug === 'acme-orders');
  if (found) {
    if (found.endpoint !== MCP_ENDPOINT) await rest('PATCH', `/v1/mcp-servers/${found.id}`, { endpoint: MCP_ENDPOINT });
    return found.id;
  }
  const created = await rest('POST', '/v1/mcp-servers', {
    slug: 'acme-orders', name: 'Acme orders', transport: 'http', endpoint: MCP_ENDPOINT, auth_kind: 'bearer',
  });
  return created.id;
}
ResourceNatural keyConverge by
/v1/mcp-serversslugPATCH endpoint / auth_kind / enabled
/v1/connectionsmcp_server_id + namePATCH credentials (re-encrypted); never readable back
/v1/agentsslugCompare the published manifest; PUT a draft and publish when it differs
/v1/tool-grantsagent_id + server + tool_pattern + effectCreate when absent
/v1/approval-policiesnamePATCH fields
/v1/workflowsslugCompare the published graph; PUT a draft and publish when it differs
/v1/triggersname + kindPATCH config / enabled (kind and target are immutable — recreate to retarget)
/v1/api-keysnameProbe the stored key; mint a new one only when it fails
/v1/webhooksurlPATCH events / description

Order matters

  1. MCP server, then its connection — the agent manifest references the server slug.
  2. Agent (create → publish; or draft → publish) — the workflow’s agent node needs the published agent_version_id.
  3. Tool grants and approval policies — they reference the agent id.
  4. Workflow (create → publish) — the trigger needs a published target.
  5. Trigger, API key, webhook endpoint — these return the secrets you must keep.

Secrets are shown once

Trigger secrets, webhook signing secrets and API keys are returned in the create response and never again. The script’s job is to keep them: write them to the local .env (development) or your secret manager (everywhere else), and on re-run reuse what is stored. Rotate only when the stored secret is gone or compromised: both rotate-secret routes generate the new value server-side and return it once, so there is no overlap window — rotate, then deploy the returned value straight away (expect a brief 401 window), or for zero downtime create a second trigger or endpoint, cut over, then delete the old one.

TypeScript
// Secrets are returned once. Keep them; only rotate when they are gone.
async function ensureTrigger(workflowId) {
  const found = (await rest('GET', '/v1/triggers?limit=100')).items
    .find((t) => t.name === TRIGGER_NAME && t.kind === 'webhook');
  if (found) {
    if (env.HIVE_TRIGGER_ID === found.id && env.HIVE_TRIGGER_SECRET) return { id: found.id, secret: env.HIVE_TRIGGER_SECRET };
    const rotated = await rest('POST', `/v1/triggers/${found.id}/rotate-secret`); // old secret dies now
    return { id: found.id, secret: rotated.secret };
  }
  const created = await rest('POST', '/v1/triggers', {
    name: TRIGGER_NAME, kind: 'webhook', target_kind: 'workflow', target_id: workflowId, config: {},
  });
  return { id: created.id, secret: created.secret };
}

Environment layout

.envShell
# Inputs (from your secret manager / CI variables)
HIVE_API_URL=https://console.agenticworkforce.me
HIVE_ADMIN_API_KEY=hive_        # admin key used ONLY by the setup script
HIVE_TENANT_ID=…
PUBLIC_URL=https://api.acme.example

# Outputs written by the script — shown by the platform exactly once, so the script keeps them
HIVE_API_KEY=hive_              # the service's own key
HIVE_TRIGGER_ID=…
HIVE_TRIGGER_SECRET=…            # x-hive-signature
HIVE_WEBHOOK_SECRET=…            # Hive-Signature
MCP_BEARER_TOKEN=…               # what your MCP server expects; stored platform-side encrypted
  • Two keys. The setup script uses an admin key that never reaches the running service; the service gets its own key (named after the integration) so rotation and audit are per-integration.
  • MCP bearer. Generate the token the script gives the platform (credentials.token on the connection) and put the same value in your service’s environment; the platform stores it encrypted and only decrypts it inside the Tool Gateway per call.
  • Public URL. The one input that differs per environment. A change means patching the MCP server endpoint and the webhook URL — the script does both.

Running it

  • Development: npm run setup once per tenant; re-run after changing the manifest, graph or public URL.
  • CI/CD: run the script in the deploy job with the environment’s admin key from the secret store; it is idempotent, so running it on every deploy is the simplest way to keep the platform in step with the code that talks to it. Print ids, never secrets.
  • Teardown: keep the inverse (--teardown) so ephemeral environments can be removed cleanly; delete in reverse order (webhooks, triggers, workflows, policies, grants, connections, agents, MCP server, API key).
  • Reset (demos and test tenants): the demo portals also ship a reset script that wipes their own database back to seed data; Platform-side state is left alone because setup-hive converges it.

The example’s script, in full

This is scripts/setup-hive.mjs from the runnable example — ~250 lines covering every resource type in the table above, in the right order, with --teardown.

examples/backend-integration-node/scripts/setup-hive.mjsJavaScript
#!/usr/bin/env node
// Provisions the platform side of this integration as code — idempotently, so it
// is safe to re-run in every environment. Mirrors the demo portals' setup-hive
// scripts. Order matters: the MCP server must exist before the agent manifest
// that references it can be published.
//
//   MCP server (acme-orders)  -> connection (bearer, encrypted at rest)
//   triage agent (published)  -> tool grant orders.get  -> workflow (published) -> webhook trigger
//   refund agent (published)  -> tool grant orders.*    -> approval policy on orders.refund
//   integration API key       -> webhook endpoint (signing secret shown once)
//
// Why two agents: an approval policy on an agent tool pauses a *direct* run
// (POST /v1/agents/:id/runs, threads) until a human decides. Inside a workflow
// an agent node cannot be resumed, so a child run that pauses for approval fails
// the workflow (RunError CHILD_RUN_FAILED). Read-only triage therefore runs in
// the trigger → workflow path; the gated refund runs as a direct agent run —
// the same split the Meridian demo makes between intake and its payment clerk.
//
// Required env (a tenant ADMIN key — the integration gets its own key below):
//   HIVE_API_URL       e.g. http://localhost:4000 (the API_PORT default of a local stack)
//   HIVE_ADMIN_API_KEY hive_… (admin)
//   HIVE_TENANT_ID     tenant uuid
//   PUBLIC_URL         where the platform can reach THIS service, e.g. https://abc.trycloudflare.com
//                      (local worker: http://host.docker.internal:4100 + A2A_ALLOW_INSECURE_HOSTS)
// Optional:
//   HIVE_MODEL         model id for the agent (default openai/gpt-oss-20b)
//
// `node scripts/setup-hive.mjs --teardown` removes everything it created.
import { randomBytes } from 'node:crypto';
import { existsSync, readFileSync, writeFileSync } from 'node:fs';
import { dirname, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';

const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..');
const ENV_PATH = resolve(ROOT, '.env');
const existing = readEnvFile(ENV_PATH);
const env = (name, fallback) => process.env[name] ?? existing.get(name) ?? fallback;

const BASE_URL = env('HIVE_API_URL', 'http://localhost:4000').replace(/\/$/, '');
const ADMIN_KEY = env('HIVE_ADMIN_API_KEY');
const TENANT_ID = env('HIVE_TENANT_ID');
const PUBLIC_URL = env('PUBLIC_URL', 'http://host.docker.internal:4100').replace(
  /\/$/,
  '',
);
const MODEL = env('HIVE_MODEL', 'openai/gpt-oss-20b');
const TEARDOWN = process.argv.includes('--teardown');

if (!ADMIN_KEY || !TENANT_ID) {
  console.error('HIVE_ADMIN_API_KEY and HIVE_TENANT_ID are required.');
  process.exit(1);
}

const MCP_SERVER_SLUG = 'acme-orders';
const CONNECTION_NAME = 'acme-orders bearer';
const TRIAGE_AGENT_SLUG = 'acme-triage-agent';
const REFUND_AGENT_SLUG = 'acme-refund-agent';
const POLICY_NAME = 'Refunds need a human (acme-orders)';
const WORKFLOW_SLUG = 'acme-order-escalation';
const TRIGGER_NAME = 'Order escalated (acme-orders)';
const API_KEY_NAME = 'acme-orders integration';
const WEBHOOK_URL = `${PUBLIC_URL}/hive/webhook`;
const MCP_ENDPOINT = `${PUBLIC_URL}/mcp`;

// ── REST helper (admin key) ────────────────────────────────────────────────
async function rest(method, path, body) {
  const res = await fetch(`${BASE_URL}${path}`, {
    method,
    headers: {
      authorization: `Bearer ${ADMIN_KEY}`,
      'x-tenant-id': TENANT_ID,
      ...(body !== undefined ? { 'content-type': 'application/json' } : {}),
    },
    body: body !== undefined ? JSON.stringify(body) : undefined,
  });
  const text = await res.text();
  const json = text.length > 0 ? JSON.parse(text) : null;
  if (!res.ok) {
    throw new Error(
      `${method} ${path} → ${res.status} ${json?.code ?? ''}: ${json?.detail ?? text}`,
    );
  }
  return json;
}

function readEnvFile(path) {
  const map = new Map();
  if (!existsSync(path)) return map;
  for (const line of readFileSync(path, 'utf8').split('\n')) {
    const m = /^([A-Z0-9_]+)=(.*)$/.exec(line.trim());
    if (m) map.set(m[1], m[2]);
  }
  return map;
}

function writeEnvFile(values) {
  const merged = new Map(existing);
  for (const [k, v] of Object.entries(values)) merged.set(k, v);
  const lines = [
    '# Written by scripts/setup-hive.mjs — server.mjs reads this at boot.',
    '# Secrets are shown by the platform exactly once; re-running the script keeps them.',
    ...[...merged.entries()].map(([k, v]) => `${k}=${v}`),
    '',
  ];
  writeFileSync(ENV_PATH, lines.join('\n'));
}

// ── 1. MCP server + encrypted connection ───────────────────────────────────
async function ensureMcpServer() {
  const { items } = await rest('GET', '/v1/mcp-servers?limit=100');
  const found = items.find((s) => s.slug === MCP_SERVER_SLUG);
  if (found) {
    if (found.endpoint !== MCP_ENDPOINT) {
      await rest('PATCH', `/v1/mcp-servers/${found.id}`, { endpoint: MCP_ENDPOINT });
      console.log(`✓ mcp server ${MCP_SERVER_SLUG} endpoint → ${MCP_ENDPOINT}`);
    } else console.log(`✓ mcp server ${MCP_SERVER_SLUG} exists — ${found.id}`);
    return found.id;
  }
  const created = await rest('POST', '/v1/mcp-servers', {
    slug: MCP_SERVER_SLUG,
    name: 'Acme orders',
    transport: 'http',
    endpoint: MCP_ENDPOINT,
    auth_kind: 'bearer',
    enabled: true,
    meta: {
      description: 'Order system exposed to agents (backend-integration-node example).',
    },
  });
  console.log(`✓ mcp server ${MCP_SERVER_SLUG} created — ${created.id}`);
  return created.id;
}

async function ensureConnection(serverId, bearerToken) {
  const { items } = await rest('GET', '/v1/connections?limit=100');
  const found = items.find(
    (c) => c.mcp_server_id === serverId && c.name === CONNECTION_NAME,
  );
  if (found) {
    await rest('PATCH', `/v1/connections/${found.id}`, {
      credentials: { token: bearerToken },
    });
    console.log(`✓ connection "${CONNECTION_NAME}" exists — credential synced`);
    return found.id;
  }
  const created = await rest('POST', '/v1/connections', {
    mcp_server_id: serverId,
    name: CONNECTION_NAME,
    credentials: { token: bearerToken },
    credential_meta: { kind: 'service bearer', service: PUBLIC_URL },
  });
  console.log(`✓ connection "${CONNECTION_NAME}" created (encrypted at rest)`);
  return created.id;
}

// ── 2. The agents (published; converged when the manifest drifts) ──────────
const TRIAGE_AGENT = {
  slug: TRIAGE_AGENT_SLUG,
  name: 'Acme triage agent',
  description: 'Reads an escalated order and recommends whether to refund.',
  allow: ['orders.get'],
  prompt: [
    'You are the order triage agent for Acme orders. You receive an escalation event as JSON.',
    'Act, do not deliberate: your first step is always the orders.get tool call for the order_id in the event.',
    'You cannot refund; a separate refund clerk does that after a human approves.',
    'Finish with a one-paragraph recommendation: order id, whether a refund is warranted (only delivered or shipped orders qualify), the recommended amount in minor units (the remaining balance unless the event names a smaller amount) and the reason.',
  ].join(' '),
};

const REFUND_AGENT = {
  slug: REFUND_AGENT_SLUG,
  name: 'Acme refund clerk',
  description: 'Executes an approved refund through the orders system.',
  allow: ['orders.get', 'orders.refund'],
  prompt: [
    'You are the refund clerk for Acme orders. You receive a refund request as JSON.',
    'Act, do not deliberate: your first step is always the orders.get tool call for the order_id in the request.',
    'Then refund with the orders.refund tool only when the order status is delivered or shipped. Refund the full remaining balance unless the request names a smaller amount_minor; pass the request idempotency_key as idempotency_key and the request reason as reason.',
    'If a refund is not appropriate, do not call orders.refund; explain why in one sentence.',
    'Finish with a one-paragraph summary: order id, what you did, amount, and the refund id if any.',
  ].join(' '),
};

function buildManifest(spec) {
  return {
    schema_version: '2.0',
    identity: {
      name: spec.name,
      description: spec.description,
      persona: { system_prompt: spec.prompt, tone: 'professional', language: 'en' },
    },
    // Reasoning models spend output tokens on thinking before the tool call —
    // keep the budget generous or the run ends mid-thought without acting.
    model: { model: MODEL, temperature: 0.1, max_output_tokens: 4000 },
    tools: {
      builtin: [],
      mcp: [{ server: MCP_SERVER_SLUG, allow: spec.allow }],
    },
    guardrails: { max_steps: 8, blocked_topics: [] },
    hitl: { default: 'auto', rules: [] },
  };
}

async function ensureUnit() {
  const tree = await rest('GET', '/v1/org/tree');
  const flat = [];
  const walk = (nodes) => {
    for (const n of nodes) {
      flat.push(n);
      walk(n.children ?? []);
    }
  };
  walk(tree.items);
  const unit = flat.find((n) => n.kind === 'unit');
  if (unit) return unit.id;
  const dept = await rest('POST', '/v1/org/nodes', {
    kind: 'department',
    name: 'Operations',
    slug: 'acme-ops',
  });
  const section = await rest('POST', '/v1/org/nodes', {
    parent_id: dept.id,
    kind: 'section',
    name: 'Customer care',
    slug: 'acme-care',
  });
  const created = await rest('POST', '/v1/org/nodes', {
    parent_id: section.id,
    kind: 'unit',
    name: 'Refunds',
    slug: 'acme-refunds',
  });
  console.log(`✓ org chain created (department → section → unit) — ${created.id}`);
  return created.id;
}

async function publishedVersion(agentId) {
  const { items } = await rest('GET', `/v1/agents/${agentId}/versions`);
  return items
    .filter((v) => v.published_at !== null)
    .sort((a, b) => b.version - a.version)[0];
}

async function ensureAgent(spec) {
  const manifest = buildManifest(spec);
  const { items } = await rest('GET', '/v1/agents?limit=100');
  let agentId = items.find((a) => a.slug === spec.slug)?.id;
  if (!agentId) {
    const created = await rest('POST', '/v1/agents', {
      node_id: await ensureUnit(),
      slug: spec.slug,
      name: manifest.identity.name,
      description: manifest.identity.description,
      manifest,
    });
    agentId = created.agent.id;
    await rest('POST', `/v1/agents/${agentId}/versions/1/publish`);
    console.log(`✓ agent ${spec.slug} created + published (v1) — ${agentId}`);
  } else {
    const published = await publishedVersion(agentId);
    const current = published
      ? await rest('GET', `/v1/agents/${agentId}/versions/${published.version}`)
      : undefined;
    const same =
      current?.manifest?.model?.model === MODEL &&
      current?.manifest?.model?.max_output_tokens === manifest.model.max_output_tokens &&
      current?.manifest?.identity?.persona?.system_prompt === spec.prompt &&
      JSON.stringify(current?.manifest?.tools?.mcp?.[0]?.allow) ===
        JSON.stringify(spec.allow);
    if (same) console.log(`✓ agent ${spec.slug} up to date — ${agentId}`);
    else {
      const draft = await rest('PUT', `/v1/agents/${agentId}/versions/draft`, {
        manifest,
        changelog: `converge ${spec.slug} (setup-hive)`,
      });
      await rest('POST', `/v1/agents/${agentId}/versions/${draft.version}/publish`);
      console.log(`✓ agent ${spec.slug} upgraded → v${draft.version} published`);
    }
  }
  const version = await publishedVersion(agentId);
  return { id: agentId, versionId: version.id };
}

// ── 3. Default-deny grants + the approval policy (the HITL gate) ──────────
async function ensureToolGrant(serverId, connectionId, agentId, pattern) {
  const { items } = await rest('GET', `/v1/tool-grants?limit=200&agent_id=${agentId}`);
  const exists = items.some(
    (g) =>
      g.mcp_server_id === serverId && g.tool_pattern === pattern && g.effect === 'allow',
  );
  if (exists) return console.log(`✓ tool grant ${pattern} exists`);
  await rest('POST', '/v1/tool-grants', {
    agent_id: agentId,
    source: 'mcp',
    mcp_server_id: serverId,
    tool_pattern: pattern,
    effect: 'allow',
    connection_id: connectionId,
  });
  console.log(`✓ tool grant ${pattern} created (agent-scoped, allow)`);
}

async function ensureApprovalPolicy(agentId) {
  const { items } = await rest('GET', '/v1/approval-policies?limit=100');
  if (items.some((p) => p.name === POLICY_NAME))
    return console.log(`✓ approval policy exists`);
  await rest('POST', '/v1/approval-policies', {
    name: POLICY_NAME,
    agent_id: agentId,
    tool_pattern: 'orders.refund',
    source: 'mcp',
    risk: 'high',
    required_approvals: 1,
    approver_role: 'admin',
    sla_minutes: 1440,
    enabled: true,
  });
  console.log(
    '✓ approval policy created: orders.refund → high risk, 1 approver, 24 h SLA',
  );
}

// ── 4. Workflow: start → triage agent → end ───────────────────────────────
function buildGraph(agent) {
  return {
    nodes: [
      { id: 'start', type: 'start', config: {} },
      {
        id: 'triage',
        type: 'agent',
        config: {
          agent_id: agent.id,
          agent_version_id: agent.versionId,
          input_template:
            'Triage this order escalation using your tools. Order {{input.order_id}}; reason: {{input.reason}}. Full event: {{input}}',
        },
      },
      { id: 'end', type: 'end', config: {} },
    ],
    edges: [
      { from: 'start', to: 'triage' },
      { from: 'triage', to: 'end' },
    ],
  };
}

async function ensureWorkflow(agent) {
  const graph = buildGraph(agent);
  const { items } = await rest('GET', '/v1/workflows?limit=100');
  let workflowId = items.find((w) => w.slug === WORKFLOW_SLUG)?.id;
  if (!workflowId) {
    const created = await rest('POST', '/v1/workflows', {
      slug: WORKFLOW_SLUG,
      name: 'Order escalation',
      graph,
    });
    workflowId = created.workflow.id;
    await rest('POST', `/v1/workflows/${workflowId}/versions/1/publish`);
    console.log(`✓ workflow ${WORKFLOW_SLUG} created + published (v1) — ${workflowId}`);
    return workflowId;
  }
  const versions = await rest('GET', `/v1/workflows/${workflowId}/versions`);
  const published = versions.items
    .filter((v) => v.published_at !== null)
    .sort((a, b) => b.version - a.version)[0];
  const current = published
    ? await rest('GET', `/v1/workflows/${workflowId}/versions/${published.version}`)
    : undefined;
  const node = current?.graph?.nodes?.find((n) => n.id === 'triage');
  const same =
    node?.config?.agent_version_id === agent.versionId &&
    node?.config?.input_template === graph.nodes[1].config.input_template;
  if (same) {
    console.log(`✓ workflow ${WORKFLOW_SLUG} up to date — ${workflowId}`);
    return workflowId;
  }
  const draft = await rest('PUT', `/v1/workflows/${workflowId}/versions/draft`, {
    graph,
  });
  await rest('POST', `/v1/workflows/${workflowId}/versions/${draft.version}/publish`);
  console.log(`✓ workflow ${WORKFLOW_SLUG} upgraded → v${draft.version} published`);
  return workflowId;
}

// ── 5. Webhook trigger (HMAC secret shown once; kept in .env) ─────────────
async function ensureTrigger(workflowId) {
  const { items } = await rest('GET', '/v1/triggers?limit=100');
  const found = items.find((t) => t.name === TRIGGER_NAME && t.kind === 'webhook');
  if (found) {
    const kept = existing.get('HIVE_TRIGGER_SECRET');
    if (existing.get('HIVE_TRIGGER_ID') === found.id && kept) {
      console.log(`✓ trigger "${TRIGGER_NAME}" exists — keeping stored secret`);
      return { id: found.id, secret: kept };
    }
    const rotated = await rest('POST', `/v1/triggers/${found.id}/rotate-secret`);
    console.log(`✓ trigger "${TRIGGER_NAME}" exists — secret rotated`);
    return { id: found.id, secret: rotated.secret };
  }
  const created = await rest('POST', '/v1/triggers', {
    name: TRIGGER_NAME,
    kind: 'webhook',
    target_kind: 'workflow',
    target_id: workflowId,
    enabled: true,
    config: {},
  });
  console.log(
    `✓ trigger "${TRIGGER_NAME}" created — POST ${BASE_URL}/hooks/${created.id}`,
  );
  return { id: created.id, secret: created.secret };
}

// ── 6. A dedicated API key for the service (the admin key stays out) ──────
async function ensureApiKey() {
  const kept = existing.get('HIVE_API_KEY');
  if (kept) {
    const probe = await fetch(`${BASE_URL}/v1/agents?limit=1`, {
      headers: { authorization: `Bearer ${kept}`, 'x-tenant-id': TENANT_ID },
    });
    if (probe.ok) {
      console.log('✓ integration API key in .env still valid — keeping it');
      return kept;
    }
  }
  const created = await rest('POST', '/v1/api-keys', { name: API_KEY_NAME });
  console.log(`✓ API key "${API_KEY_NAME}" created — ${created.prefix}`);
  return created.secret;
}

// ── 7. Webhook endpoint (signing secret shown once; kept in .env) ─────────
async function ensureWebhookEndpoint() {
  const { items } = await rest('GET', '/v1/webhooks');
  const found = items.find((e) => e.url === WEBHOOK_URL);
  if (found) {
    const kept = existing.get('HIVE_WEBHOOK_SECRET');
    if (kept) {
      console.log(`✓ webhook endpoint exists (${WEBHOOK_URL}) — keeping stored secret`);
      return kept;
    }
    const rotated = await rest('POST', `/v1/webhooks/${found.id}/rotate-secret`);
    console.log('✓ webhook endpoint exists — signing secret rotated');
    return rotated.secret;
  }
  const created = await rest('POST', '/v1/webhooks', {
    url: WEBHOOK_URL,
    events: ['run.completed', 'run.failed', 'approval.requested'],
    description: 'acme-orders example — run + approval lifecycle',
  });
  console.log(`✓ webhook endpoint created → ${WEBHOOK_URL}`);
  return created.secret;
}

// ── Teardown: delete what this script owns (by slug / name / url) ─────────
async function teardown() {
  const del = async (label, path) => {
    await rest('DELETE', path);
    console.log(`✗ ${label} deleted`);
  };
  const hooks = await rest('GET', '/v1/webhooks');
  for (const e of hooks.items.filter((e) => e.url === WEBHOOK_URL))
    await del('webhook endpoint', `/v1/webhooks/${e.id}`);
  const triggers = await rest('GET', '/v1/triggers?limit=100');
  for (const t of triggers.items.filter((t) => t.name === TRIGGER_NAME))
    await del('trigger', `/v1/triggers/${t.id}`);
  const workflows = await rest('GET', '/v1/workflows?limit=100');
  for (const w of workflows.items.filter((w) => w.slug === WORKFLOW_SLUG))
    await del('workflow', `/v1/workflows/${w.id}`);
  const policies = await rest('GET', '/v1/approval-policies?limit=100');
  for (const p of policies.items.filter((p) => p.name === POLICY_NAME))
    await del('approval policy', `/v1/approval-policies/${p.id}`);
  const servers = await rest('GET', '/v1/mcp-servers?limit=100');
  const server = servers.items.find((s) => s.slug === MCP_SERVER_SLUG);
  if (server) {
    const grants = await rest('GET', '/v1/tool-grants?limit=200');
    for (const g of grants.items.filter((g) => g.mcp_server_id === server.id))
      await del('tool grant', `/v1/tool-grants/${g.id}`);
    const conns = await rest('GET', '/v1/connections?limit=100');
    for (const c of conns.items.filter((c) => c.mcp_server_id === server.id))
      await del('connection', `/v1/connections/${c.id}`);
  }
  const agents = await rest('GET', '/v1/agents?limit=100');
  for (const a of agents.items.filter((a) =>
    [TRIAGE_AGENT_SLUG, REFUND_AGENT_SLUG].includes(a.slug),
  ))
    await del(`agent ${a.slug}`, `/v1/agents/${a.id}`);
  if (server) await del('mcp server', `/v1/mcp-servers/${server.id}`);
  const keys = await rest('GET', '/v1/api-keys');
  for (const k of keys.items.filter(
    (k) => k.name === API_KEY_NAME && k.revoked_at === null,
  ))
    await del('api key', `/v1/api-keys/${k.id}`);
  console.log('Teardown complete. .env was left in place — delete it if you are done.');
}

// ── Run ───────────────────────────────────────────────────────────────────
if (TEARDOWN) {
  await teardown();
  process.exit(0);
}

console.log(
  `Provisioning acme-orders on tenant ${TENANT_ID} (${BASE_URL}) → ${PUBLIC_URL}`,
);
const bearerToken = existing.get('MCP_BEARER_TOKEN') || randomBytes(32).toString('hex');
const mcpServerId = await ensureMcpServer();
const connectionId = await ensureConnection(mcpServerId, bearerToken);
const triage = await ensureAgent(TRIAGE_AGENT);
await ensureToolGrant(mcpServerId, connectionId, triage.id, 'orders.get');
const refund = await ensureAgent(REFUND_AGENT);
await ensureToolGrant(mcpServerId, connectionId, refund.id, 'orders.*');
await ensureApprovalPolicy(refund.id);
const workflowId = await ensureWorkflow(triage);
const trigger = await ensureTrigger(workflowId);
const apiKey = await ensureApiKey();
const webhookSecret = await ensureWebhookEndpoint();

writeEnvFile({
  HIVE_API_URL: BASE_URL,
  HIVE_TENANT_ID: TENANT_ID,
  HIVE_API_KEY: apiKey,
  HIVE_TRIGGER_ID: trigger.id,
  HIVE_TRIGGER_SECRET: trigger.secret,
  HIVE_WEBHOOK_SECRET: webhookSecret,
  MCP_BEARER_TOKEN: bearerToken,
  PUBLIC_URL,
  HIVE_TRIAGE_AGENT_ID: triage.id,
  HIVE_REFUND_AGENT_ID: refund.id,
  HIVE_WORKFLOW_ID: workflowId,
});

console.log(`\nWrote ${ENV_PATH}`);
console.log('Integration surfaces:');
console.log(
  `  MCP server   ${MCP_SERVER_SLUG} → ${MCP_ENDPOINT} (bearer, encrypted connection)`,
);
console.log(
  `  Workflow     ${WORKFLOW_SLUG} (published, ${TRIAGE_AGENT_SLUG}) ← trigger POST ${BASE_URL}/hooks/${trigger.id}`,
);
console.log(
  `  Direct runs  ${REFUND_AGENT_SLUG} ← POST ${BASE_URL}/v1/agents/${refund.id}/runs`,
);
console.log(`  HITL gate    orders.refund (approval policy, admin, 24 h)`);
console.log(
  `  Webhook      ${WEBHOOK_URL} (run.completed / run.failed / approval.requested)`,
);
console.log(
  'Next: node server.mjs, then POST /orders/ord_1001/escalate and POST /orders/ord_1001/refund',
);