Guide/connections7 min read

Build a Gusto payroll and onboarding readiness agent

Multi-tenant Gusto readiness on Naïve: read-only payroll blockers and onboarding status, Account Kit tool allowlists, full connect lifecycle, client.llm summaries.

Read the docs →

Guide/connections
TL;DR
  • One tenant user per customer; each connects their own Gusto company on a governed agent profile
  • Starter kit: explicit read-only Gusto tools blockers, employees, onboarding status
  • Pro kit: adds Gmail send for human-reviewed manager reminders
  • connections.connect may pending_approval; connections.execute is kit-filtered not execute-approval-gated today
  • No agent payroll submit in this guide humans review and submit in Gusto

This is a developer tutorial against @usenaive-sdk/server. By the end you will have a multi-tenant readiness agent: each customer connects their existing Gusto company, your backend runs read-only Gusto tools, and client.llm produces manager-facing summaries — with policy enforced by Account Kits, not prompts.

Intro: Governed HR agents in your customers' existing Gusto companies.

Job to be done: "Which employees or onboarding gaps will block Friday's payroll?"

What we're building

PieceRole
UsersOne tenant user per customer — isolation boundary
Account KitsPlan tier: allowed toolkits + per-tool Gusto enable list
ConnectionsPer-customer Gusto connection + execute
LLMSummaries on client.llm, same tenant user
ApprovalsHuman gate on connect (default), not on every execute
LogsAudit per customer

Out of scope: payroll submit, autonomous I-9, dumping full employee PII into prompts.

Step 0: Install

Workspace key (nv_sk_...) from Studio → Settings → API keys. Server-only — never in the browser.

npm install @usenaive-sdk/server
import { Naive } from "@usenaive-sdk/server";
 
const naive = new Naive({ apiKey: process.env.NAIVE_API_KEY! });

Step 1: Kits with explicit Gusto read tools

Toolkit allowlist alone is not read-only. Enable named tools:

// Verified in staging (Jul 2026) — re-check schemas with connections.tools("gusto") after connect
const READ_GUSTO_TOOLS = [
  "GUSTO_GET_TOKEN_INFO", // no args — bootstrap company id for later calls
  "GUSTO_LIST_PAYROLL_BLOCKERS", // requires company_uuid
  "GUSTO_LIST_EMPLOYEES", // requires company_id
  "GUSTO_LIST_EMPLOYEE_ONBOARDING_STATUS", // requires employee_id
  "GUSTO_GET_COMPANY_ONBOARDING_STATUS", // requires company_uuid
];

Starter — readiness reads only

const starterKit = await naive.accountKits.create({
  name: "Gusto Readiness — Starter",
  primitives_config: {
    llm: { enabled: true },
  },
  connections_config: {
    mode: "allowlist",
    toolkits: ["gusto"],
    tools: { gusto: { enable: READ_GUSTO_TOOLS } },
  },
});

Pro — adds Gmail for human-reviewed sends

const proKit = await naive.accountKits.create({
  name: "Gusto Readiness — Pro",
  primitives_config: {
    llm: { enabled: true },
  },
  connections_config: {
    mode: "allowlist",
    toolkits: ["gusto", "gmail"],
    tools: {
      gusto: { enable: READ_GUSTO_TOOLS },
      gmail: { enable: ["GMAIL_SEND_EMAIL"] },
    },
  },
});

See Connections docs for the full connect lifecycle.

Step 2: Provision a customer

const customer = await naive.users.create({
  external_id: "acme-corp",
  email: "ops@acme.com",
  label: "Acme Corp",
  account_kit_id: starterKit.id,
});
 
const client = naive.forUser(customer.id);

Everything below uses client, not the root naive client.

Step 3: Connect Gusto

connections.connect is approval-gated by default for agent calls. Execute runs when the kit allows it — it does not create an approval for each execute.

import { isPendingApproval } from "@usenaive-sdk/server";
 
async function startGustoConnect(client: ReturnType<typeof naive.forUser>) {
  const callbackUrl = "https://yourapp.com/connections/gusto/done";
 
  let res = await client.connections.connect("gusto", { callbackUrl });
 
  if (isPendingApproval(res)) {
    const approval = await client.approvals.wait(res.approval_id);
    if (approval.status !== "approved") {
      throw new Error(`Gusto connect denied: ${approval.status}`);
    }
    res = await client.connections.connect("gusto", { callbackUrl });
  }
 
  if (isPendingApproval(res)) {
    throw new Error("Gusto connect still pending approval after retry");
  }
  if (!res.redirectUrl) {
    throw new Error("No redirectUrl — check connection status or kit policy");
  }
 
  return res.redirectUrl; // Send the customer here to connect their Gusto company
}

After the customer completes the connection, confirm status is ACTIVE:

const { connections } = await client.connections.connected();
const gusto = connections.find((c) => c.toolkit === "gusto");
if (gusto?.status !== "ACTIVE") {
  throw new Error("Gusto not connected yet — customer must finish the connect flow");
}

Step 4: Resolve the Gusto company id

Most Gusto read tools require a company_uuid or company_id. After connect, bootstrap from the token:

async function gustoCompanyIds(client: ReturnType<typeof naive.forUser>) {
  const token = await client.connections.execute("gusto", "GUSTO_GET_TOKEN_INFO", {});
  if (!token.successful) throw new Error(token.error ?? "GUSTO_GET_TOKEN_INFO failed");
 
  // Inspect token.data in your workspace — field names match the connections schema
  const data = token.data as { company_uuid?: string; company_id?: string; resource?: { uuid?: string } };
  const companyUuid = data.company_uuid ?? data.resource?.uuid;
  const companyId = data.company_id ?? companyUuid;
  if (!companyUuid) throw new Error("Could not resolve Gusto company id from token");
 
  return { companyUuid, companyId };
}

Step 5: Job A — payroll blockers

connections.execute returns { successful, error, data } (docs). GUSTO_LIST_PAYROLL_BLOCKERS requires company_uuid (verified in staging).

async function payrollReadiness(client: ReturnType<typeof naive.forUser>) {
  const { companyUuid } = await gustoCompanyIds(client);
 
  const result = await client.connections.execute(
    "gusto",
    "GUSTO_LIST_PAYROLL_BLOCKERS",
    { company_uuid: companyUuid },
  );
 
  if (!result.successful) {
    throw new Error(result.error ?? "GUSTO_LIST_PAYROLL_BLOCKERS failed");
  }
 
  const payload = result.data as { blockers?: { type?: string; employee_id?: string; message?: string }[] };
  const safe = {
    blocker_count: payload.blockers?.length ?? 0,
    summary_fields: payload.blockers?.slice(0, 20)?.map((b) => ({
      type: b.type,
      employee_id: b.employee_id,
      message: b.message,
    })),
  };
 
  const { choices } = await client.llm.chat({
    model: "anthropic/claude-sonnet-4.6",
    messages: [{
      role: "user",
      content: `You are a payroll ops assistant. List blocking issues before the next run. Be concise.\n\n${JSON.stringify(safe)}`,
    }],
  });
 
  return choices[0]?.message?.content ?? "";
}

Humans submit payroll in Gusto. The agent does not call a submit tool in this guide.

Step 6: Job B — incomplete onboarding

Company-level status (company_uuid), then list employees (company_id), then per-employee status (employee_id) — arg names differ by tool; verified in staging.

async function incompleteOnboarding(client: ReturnType<typeof naive.forUser>) {
  const { companyUuid, companyId } = await gustoCompanyIds(client);
 
  const companyStatus = await client.connections.execute(
    "gusto",
    "GUSTO_GET_COMPANY_ONBOARDING_STATUS",
    { company_uuid: companyUuid },
  );
  if (!companyStatus.successful) {
    throw new Error(companyStatus.error ?? "GUSTO_GET_COMPANY_ONBOARDING_STATUS failed");
  }
 
  const listResult = await client.connections.execute("gusto", "GUSTO_LIST_EMPLOYEES", {
    company_id: companyId,
  });
  if (!listResult.successful) {
    throw new Error(listResult.error ?? "GUSTO_LIST_EMPLOYEES failed");
  }
 
  const { employees } = listResult.data as {
    employees?: { uuid: string; first_name?: string; last_name?: string }[];
  };
 
  const gaps: { name: string; employee_id: string; incomplete_steps: unknown }[] = [];
 
  for (const emp of (employees ?? []).slice(0, 25)) {
    const statusResult = await client.connections.execute(
      "gusto",
      "GUSTO_LIST_EMPLOYEE_ONBOARDING_STATUS",
      { employee_id: emp.uuid },
    );
    if (!statusResult.successful) continue;
 
    const incomplete = (statusResult.data as { incomplete_steps?: unknown[] })?.incomplete_steps;
    if (!incomplete || incomplete.length === 0) continue;
 
    gaps.push({
      name: `${emp.first_name ?? ""} ${emp.last_name ?? ""}`.trim(),
      employee_id: emp.uuid,
      incomplete_steps: incomplete,
    });
  }
 
  const safe = {
    company_onboarding: companyStatus.data,
    gap_count: gaps.length,
    gaps: gaps.slice(0, 20),
  };
 
  const { choices } = await client.llm.chat({
    model: "anthropic/claude-sonnet-4.6",
    messages: [{
      role: "user",
      content: `Draft a short manager email listing hires with incomplete onboarding. Do not include SSN or bank details.\n\n${JSON.stringify(safe)}`,
    }],
  });
 
  return choices[0]?.message?.content ?? "";
}

I-9 and tax filing remain human responsibilities in Gusto. The agent surfaces status and drafts reminders only.

Step 7: Human-reviewed send (Pro tier)

Do not auto-send sensitive HR mail. Surface the draft in your UI; on confirm:

await client.connections.execute("gmail", "GMAIL_SEND_EMAIL", {
  recipient_email: "manager@customer.com",
  subject: "Onboarding gaps before payroll",
  body: draftText,
});

Step 8: Optional weekday cron (advanced)

Scheduled jobs require the cron primitive on the kit and an orchestration runtime for the tenant user (cron docs). Validate in your workspace before shipping this path.

const cronKit = await naive.accountKits.create({
  name: "Gusto Readiness — Scheduled",
  primitives_config: {
    llm: { enabled: true },
    cron: { enabled: true },
  },
  connections_config: {
    mode: "allowlist",
    toolkits: ["gusto"],
    tools: { gusto: { enable: READ_GUSTO_TOOLS } },
  },
});
 
await client.cron.create({
  schedule: "0 8 * * 1-5",
  name: "Gusto readiness check",
  prompt:
    "Run payroll blocker and onboarding gap checks via Gusto tools, then produce a one-paragraph ops summary.",
});

When things go wrong

SymptomLikely causeFix
forbidden on executeTool slug not on tools.gusto.enable, or toolkit not allowlistedUpdate Account Kit enable list
pending_approval on connectDefault agent connect gateApprove via client.approvals, then retry connect
Execute returns successful: falseWrong args, expired connection, or Gusto API errorInspect result.error; check connections.connected() status
Tool slug not foundCatalog rename or pagination missRe-run connections.tools("gusto") (raise limit / search); update kit
Arg validation errorWrong field (company_uuid vs company_id, employee_id)Match the tool schema from connections.tools
Cron job never runs Gusto toolscron not enabled on kit, or no orchestration runtimeEnable primitive; see orchestration docs

Privacy and errors

  • Minimize model input — summarize derived fields; avoid pasting full Gusto payloads with SSN, bank, or compensation history.
  • forbidden — tool not on kit enable list or toolkit not allowlisted.
  • pending_approval on connect — expected for agent-initiated connect; not a substitute for blocking dangerous execute tools.
  • Tenant isolation — always naive.forUser(customerId); Customer A's Gusto grant never serves Customer B.

Roadmap (honest gaps)

CapabilityStatus
Read payroll blockers / onboarding statusDocumented above — verify slugs and args in your workspace
Agent payroll submitNot verified in catalog — do not ship until tool + policy exist
Execute-level approval on Gusto writesNot default today — use per-tool disable + product-side human gates

When submit tools and execute gating ship and are tested, a separate post can cover approval-gated payroll actions.

Where to go next

Frequently Asked Questions
Who is this for?+
Multi-tenant HR, PEO, or payroll-adjacent SaaS whose SMB customers already run Gusto. You ship readiness monitoring and onboarding nudges — not a Gusto replacement.
Can agents submit payroll?+
Not in this guide. Summarize blockers and link humans to Gusto to review and submit. Do not ship agent payroll submit until a tool slug and policy path are verified in your workspace.
Can agents complete I-9 or tax steps?+
No. Agents may read onboarding status and draft reminders. Regulated employer steps stay with humans in Gusto.
Why not allowlist only toolkits: ["gusto"]?+
That permits the full Gusto surface (a large catalog with many write tools). Use tools.gusto.enable with an explicit read list for Starter tiers.
Does connections.tools show only what my kit allows?+
No. It lists the toolkit catalog (default ~100 tools per page, API max 200). Kit policy is enforced when you call connections.execute — disabled tools return forbidden.
Do customers sign into Naïve?+
No. You create tenant users via API. Customers only complete the Gusto connection at the hosted link your app serves.
NT
Naïve Team

Building the agent-native backend.

Keep reading