- ›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
| Piece | Role |
|---|---|
| Users | One tenant user per customer — isolation boundary |
| Account Kits | Plan tier: allowed toolkits + per-tool Gusto enable list |
| Connections | Per-customer Gusto connection + execute |
| LLM | Summaries on client.llm, same tenant user |
| Approvals | Human gate on connect (default), not on every execute |
| Logs | Audit 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/serverimport { 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
| Symptom | Likely cause | Fix |
|---|---|---|
forbidden on execute | Tool slug not on tools.gusto.enable, or toolkit not allowlisted | Update Account Kit enable list |
pending_approval on connect | Default agent connect gate | Approve via client.approvals, then retry connect |
Execute returns successful: false | Wrong args, expired connection, or Gusto API error | Inspect result.error; check connections.connected() status |
| Tool slug not found | Catalog rename or pagination miss | Re-run connections.tools("gusto") (raise limit / search); update kit |
| Arg validation error | Wrong field (company_uuid vs company_id, employee_id) | Match the tool schema from connections.tools |
| Cron job never runs Gusto tools | cron not enabled on kit, or no orchestration runtime | Enable 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_approvalon 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)
| Capability | Status |
|---|---|
| Read payroll blockers / onboarding status | Documented above — verify slugs and args in your workspace |
| Agent payroll submit | Not verified in catalog — do not ship until tool + policy exist |
| Execute-level approval on Gusto writes | Not 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
- Governed HR agents in Gusto
- Introducing connections
- Building AI agents into your SaaS
- Audit logging for AI agent actions
- Connections docs
Who is this for?+
Can agents submit payroll?+
Can agents complete I-9 or tax steps?+
Why not allowlist only toolkits: ["gusto"]?+
Does connections.tools show only what my kit allows?+
Do customers sign into Naïve?+
Building the agent-native backend.
Connect each customer's Gusto company to a governed agent profile on Naïve — read-only payroll and onboarding readiness today, with kit-level tool policy and audit.
The connections primitive gives each end-user's agents authenticated access to 1,000+ third-party apps — per user, gated by the Account Kit, in one surface.
An Account Kit is a reusable policy template that governs what a tenant user's AI agents can do — which primitives are enabled and which apps they can connect.
Building multi-tenant AI agents into your SaaS means giving each customer an isolated, governed agent. Here's the full architecture and how to ship it.