- ›A corporate travel desk is the cleanest test of agent-grade permissions: the agent spends real money on each employee's behalf and must obey policy it cannot talk its way around
- ›
Each employee is an isolated tenant user governed by one Account Kit— the travel policy is a server-side object, not a line in a system prompt - ›The agent researches hotels with the Travel primitive (Google Hotels + TripAdvisor), then ranks them with an LLM routed through OpenRouter via naive.llm.chat
- ›
Spend is bounded by a per-trip virtual card whose spending_limit_cents is a hard ceiling the card network enforces— a jailbroken prompt cannot raise it - ›Issuing or topping up a card is approval-gated at execution time: an out-of-policy booking returns 202 pending_approval and only runs after a manager approves, which replays the frozen action
- ›Confirmations go out from the employee's own connected Gmail, so identity and audit stay per-user end to end
A corporate travel desk is the hardest kind of agent to fake. A model can suggest a hotel in seconds — but to actually run the desk, the agent has to act as a specific employee, spend real money on their behalf, and stay inside a travel policy it has no ability to argue with. That last part is where most "AI agents" quietly fall apart: the policy lives in a system prompt, and a system prompt is a suggestion.
This guide builds the real thing with the Naïve SDK: a multi-tenant travel desk where every employee is an isolated identity, every trip is paid from a per-trip virtual card with a hard ceiling, and any out-of-policy booking is frozen by the API until a manager approves it. The enforcement is not in the prompt — it is in the card network and the approval queue.
- What you build: an agent that takes "book me 2 nights in Lisbon next week, mid-range, near the office," researches hotels, ranks them with an LLM, and books one — without ever being able to overspend or skip sign-off.
- Why it can't exist without the moat: identity per employee, a real capped card per trip, and execution-time permission enforcement are primitives here, not glue code you write and hope holds.
- Who it's for: developers embedding Naïve in a backend. Everything below is real SDK and REST, with signatures pulled straight from the docs.
The architecture
The whole desk is one governed loop. The agent does the thinking; the platform holds the parts the model must not control — identity, the spend ceiling, and the human gate.
The four primitives doing the work:
- Users — one tenant user per employee. Every card, connection, and approval is scoped to that user.
- Account Kits — one reusable policy object that says which primitives are on, which apps an employee may connect, and which actions need approval.
- Travel + LLM — research candidates, then rank them.
- Cards + Approvals — the spend rail and the human gate.
Setup
You need a Naïve API key (nv_sk_...) from the developer dashboard and Node ≥ 18.
npm install @usenaive-sdk/serverInitialize the client once. The SDK is server-only — your key is a server secret.
import { Naive, isPendingApproval } from "@usenaive-sdk/server";
const naive = new Naive({ apiKey: process.env.NAIVE_API_KEY! });The SDK uses a Stripe-style scoped-client pattern. Top-level calls (naive.cards, naive.llm, …) act on your workspace's default tenant user. To act as a specific employee, you call naive.forUser(employeeId), which returns the same surface bound to that user. That scoping is the spine of everything below.
Step 1 — Encode the travel policy as an Account Kit
This is the move that makes the rest enforceable. Instead of writing policy into a prompt, you create an Account Kit: a server-side object that controls which primitives are enabled, which third-party apps an employee may connect, and which actions require human approval.
const travelPolicy = await naive.accountKits.create({
name: "Travel — IC",
primitives_config: {
// The desk needs to issue per-trip cards — and every issuance is gated.
cards: { enabled: true, requiresApproval: true },
// No company formation, KYC, or domain spend from a travel agent.
formation: { enabled: false },
verification: { enabled: false },
},
connections_config: {
// Employees may only connect the apps the desk actually uses.
mode: "allowlist",
toolkits: ["gmail"],
// Connecting an app is itself a sensitive action; keep it gated.
requiresApproval: true,
},
});What each line buys you:
cards.requiresApproval: true— issuing or topping up a card for an employee freezes for a human instead of executing. This is the execution-time gate.connections_config.mode: "allowlist"withtoolkits: ["gmail"]— an employee's agent can connect Gmail and nothing else. Notion, Slack, GitHub, Stripe — all blocked by the kit, no extra code.- The disabled identity primitives (
formation,verification) mean a travel agent simply has no surface to form a company or start KYC, even if asked.
Gating defaults are sensible out of the box: issuing a card, topping up, connecting a service, purchasing a domain, starting KYC, and forming a company are already approval-gated for tenant users. The Account Kit is where you tighten or relax that per primitive (requiresApproval), and connections additionally accept approvalToolkits to gate only specific apps. Human (dashboard) callers and the operator's own default user are not subject to agent approval gating — only agent calls on real tenant users are frozen.
The policy is a row in your workspace, not a string in a context window. The model cannot read it, rewrite it, or route around it.
Step 2 — Provision one tenant user per employee
Each employee becomes a tenant user: an isolated identity that owns its own cards, connections, vault, and approval queue. Tenant users never log in — you manage them entirely through the API.
async function onboardEmployee(row: { id: string; email: string; name: string }) {
const user = await naive.users.create({
external_id: row.id, // your own DB id — stable, deduplicated
email: row.email,
label: row.name,
});
// Put them under the travel policy from Step 1.
await naive.accountKits.assignUser(travelPolicy.id, user.id);
return user; // user.id is what you pass to naive.forUser(...)
}From here on, every action runs through naive.forUser(user.id). One employee's resources are never visible to another, and they all inherit the same policy without per-user setup.
const alice = await onboardEmployee({
id: "emp_8841",
email: "alice@acme.com",
name: "Alice Tan",
});
const desk = naive.forUser(alice.id); // the scoped client for AliceStep 3 — Research hotels with the Travel primitive
The Travel primitive is a use-case view over Business Data, backed by Google Hotels and TripAdvisor. There is no /v1/travel route and no SDK sub-client — every call maps to a /v1/business/... endpoint, so you call it over REST (or naive business ... in the CLI). Google hotel endpoints return live results; each call costs 2 credits.
A small typed helper keeps the rest of the code clean:
const NAIVE_BASE = "https://api.usenaive.ai";
async function naiveBusiness(path: string, body: Record<string, unknown>) {
const res = await fetch(`${NAIVE_BASE}/v1/business/${path}`, {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.NAIVE_API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify(body),
});
if (!res.ok) throw new Error(`business ${path} failed: ${res.status}`);
return res.json();
}Search hotels live with Google Hotels, then pull detail on the candidates:
async function findHotels(query: string, checkIn: string, checkOut: string) {
// Live search — immediate candidates.
const { results } = await naiveBusiness("google/hotel-searches", {
keyword: query, // e.g. "hotels lisbon near baixa"
location_code: 2840,
check_in: checkIn, // "2026-06-24"
check_out: checkOut, // "2026-06-26"
adults: 1,
});
// Detailed profile + rates for the top few.
const detailed = await Promise.all(
results.slice(0, 6).map((h: { hotel_id: string }) =>
naiveBusiness("google/hotel-info", { hotel_identifier: h.hotel_id }),
),
);
return detailed;
}TripAdvisor adds broader place discovery and is async — submit a task, poll for readiness, then retrieve. Useful when the employee asks for "somewhere walkable with good restaurants nearby":
async function tripAdvisorPlaces(keyword: string) {
const { task_id } = await naiveBusiness("tripadvisor/search/task", {
keyword,
location_code: 2840,
});
// Poll tasks-ready, then retrieve. (GET endpoints; same Bearer auth.)
// GET /v1/business/tripadvisor/search/tasks-ready
// GET /v1/business/tripadvisor/search/task/{task_id}
return task_id;
}Step 4 — Rank candidates with the LLM (OpenRouter, via Naïve)
The LLM primitive is a full wrapper over OpenRouter: one OpenAI-compatible endpoint that routes to 300+ models. You don't hold an OpenRouter key — Naïve injects it server-side and bills OpenRouter's reported usage cost, converted to credits. Because the request body is OpenRouter's, you get its routing controls directly.
Here the agent turns the policy and the candidates into a single, structured decision. Three OpenRouter features earn their place:
models— a fallback chain. If the primary is unavailable, OpenRouter tries the next in order.provider— OpenRouter provider routing: sort by throughput, deny training on the data.response_format— a JSON schema, so you get a typed pick back instead of prose to parse.
type Pick = { hotel_id: string; name: string; nightly_cents: number; reason: string };
async function rankHotels(
userId: string,
brief: string,
policy: { maxNightlyCents: number; nights: number },
candidates: unknown[],
): Promise<Pick> {
// Scope the call to the employee so it's metered + AccountKit-gated per user.
const res = await naive.forUser(userId).llm.chat({
model: "anthropic/claude-sonnet-4.6",
models: ["anthropic/claude-sonnet-4.6", "openai/gpt-5.2"], // fallback chain
provider: { sort: "throughput", data_collection: "deny" }, // OpenRouter routing
messages: [
{
role: "system",
content:
"You are a corporate travel desk. Pick exactly one hotel that fits the brief " +
`and stays at or under ${policy.maxNightlyCents} cents per night for ${policy.nights} nights. ` +
"Prefer location and refundable rates. Return only the schema.",
},
{ role: "user", content: `${brief}\n\nCANDIDATES:\n${JSON.stringify(candidates)}` },
],
response_format: {
type: "json_schema",
json_schema: {
name: "hotel_pick",
strict: true,
schema: {
type: "object",
additionalProperties: false,
required: ["hotel_id", "name", "nightly_cents", "reason"],
properties: {
hotel_id: { type: "string" },
name: { type: "string" },
nightly_cents: { type: "integer" },
reason: { type: "string" },
},
},
},
},
});
console.log("ranking cost (credits):", res.credits_used);
return JSON.parse(res.choices[0].message.content) as Pick;
}The model proposes a hotel and a nightly price. It does not get to decide whether that price is allowed to be paid. That decision is enforced one layer down.
Step 5 — Book it, and watch policy get enforced at execution time
This is the moat in motion. To pay for the trip, the desk issues a virtual card scoped to the employee, capped at the trip budget. Two independent controls fire here:
- The cap is physical.
spending_limit_centsis a ceiling the card network enforces. No charge above it clears, no matter what the agent does. - The issuance is gated. Because the Account Kit set
cards.requiresApproval: true, the agent'scards.createcall doesn't execute — it returns202 pending_approval. The card is funded only after a manager approves.
Managed virtual cards require a cardholder once per company (Cards docs). Create that before the agent issues trip cards.
async function issueTripCard(userId: string, tripBudgetCents: number, label: string) {
const desk = naive.forUser(userId);
// Gated for tenant users → returns either the card or a frozen approval.
const res = await desk.cards.create({
name: label, // "Lisbon — Alice — Jun 24-26"
spending_limit_cents: tripBudgetCents, // hard ceiling for this trip
provider: "managed_virtual",
});
if (isPendingApproval(res)) {
// The action is frozen. Surface res.approval_id to the manager who signs off.
// res.action === "cards.create", res.title describes the request.
return { status: "pending_approval", approvalId: res.approval_id } as const;
}
// (Reached only if policy allows auto-issue, e.g. requiresApproval: false.)
return { status: "issued", card: res } as const;
}isPendingApproval(res) is a typed discriminator exported from the SDK. HTTP 202 is success here, not an error — the gated call resolves to either its normal result or a PendingApproval. The pending response carries everything a human needs:
{
"status": "pending_approval",
"approval_id": "65589c8b-e033-4a65-b16c-379211c94429",
"action": "cards.create",
"primitive": "cards",
"title": "Issue virtual card \"Lisbon — Alice — Jun 24-26\""
}A manager approves it in your own UI, or straight through the SDK. Approval replays the frozen action — the card is actually created at approval time, not before:
async function approveAndIssue(userId: string, approvalId: string) {
const desk = naive.forUser(userId);
await desk.approvals.approve(approvalId); // replays cards.create
const resolved = await desk.approvals.get(approvalId);
// resolved.status === "executed", resolved.result === { card_id, checkout_url, ... }
return resolved;
}You can also list what's waiting, or deny with a reason:
await naive.forUser(userId).approvals.list({ status: "pending" });
await naive.forUser(userId).approvals.deny(approvalId, { reason: "Use the conference rate" });Once the card's checkout_url is funded by finance and check-payment issues it (status: "active"), the agent pulls the credentials from the card's details endpoint and pays — and even then it can only ever spend up to the cap:
// GET /v1/cards/{card_id}/details — returns PAN/CVC for an active managed_virtual card
const res = await fetch(`${NAIVE_BASE}/v1/cards/${cardId}/details`, {
headers: { Authorization: `Bearer ${process.env.NAIVE_API_KEY}` },
});
const details = await res.json();
// details.number, details.cvc, details.exp_month, details.exp_year, details.remaining_centsThe prompt picked the hotel. The card decided how much could be spent, and the approval queue decided whether it happened at all. Neither is reachable from the model.
Step 6 — Send the itinerary from the employee's own Gmail
The booking is done as the employee, so the confirmation should come from the employee too. Connections give each tenant user authenticated access to their own third-party apps — Gmail here, per the Account Kit allowlist.
First-time connect returns a hosted redirect (and, because the kit gates connecting, may itself require approval):
const { redirectUrl } = await naive
.forUser(alice.id)
.connections.connect("gmail", { callbackUrl: "https://acme.com/oauth/callback" });
// Send Alice to redirectUrl once; after she authorizes, the connection is ACTIVE.Then the agent sends the itinerary from Alice's inbox, not a shared no-reply address:
await naive.forUser(alice.id).connections.execute("gmail", "GMAIL_SEND_EMAIL", {
recipient_email: "alice@acme.com",
subject: "Your Lisbon trip is booked — Jun 24–26",
body: "Hotel: …\nCheck-in: Jun 24\nConfirmation: …\nPaid on the per-trip card.",
});Every connection is per-user and filtered by that user's Account Kit. Alice's Gmail token is hers alone; another employee's agent can never reach it.
The full loop
End to end, the desk is one function. Each step delegates the part the model must not own to a primitive that enforces it.
async function bookTrip(
employee: { id: string; email: string },
brief: string,
dates: { in: string; out: string },
) {
const policy = { maxNightlyCents: 22000, nights: 2 }; // $220/night IC policy
const tripBudgetCents = policy.maxNightlyCents * policy.nights;
// 1. Research (Travel primitive)
const candidates = await findHotels(brief, dates.in, dates.out);
// 2. Rank (LLM primitive, via OpenRouter)
const pick = await rankHotels(employee.id, brief, policy, candidates);
// 3. Pay (Cards) — frozen by policy if it needs sign-off
const card = await issueTripCard(employee.id, tripBudgetCents, `Trip - ${pick.name}`);
if (card.status === "pending_approval") {
return { stage: "awaiting_manager", approvalId: card.approvalId, pick };
}
// 4. Confirm (Connections) — from the employee's own Gmail
await naive.forUser(employee.id).connections.execute("gmail", "GMAIL_SEND_EMAIL", {
recipient_email: employee.email,
subject: `Trip booked: ${pick.name}`,
body: pick.reason,
});
return { stage: "booked", pick };
}The happy path books a trip. The interesting path returns awaiting_manager — because the agent tried to spend money that needed a human, and the platform stopped it cold.
What you'd have to build without these primitives
The reason this is a moat is the list of systems you don't write:
- Per-employee identity and isolation — scoping every card, token, and approval to a user so one employee's resources are not exposed to another's. Here it's
naive.forUser(id). - A real, capped spend rail — issuing virtual cards, funding them, and enforcing a hard per-trip ceiling at the network. Here it's
cards.create({ spending_limit_cents }). - Execution-time permission enforcement — freezing a sensitive action before it runs, replaying it on approval, and recording the outcome — without trusting the model to behave. Here it's the Account Kit gate + the Approvals queue.
- Governed third-party access — per-user OAuth to 1,000+ apps, filtered by an allowlist you set once. Here it's
connections+ the Account Kit. - Model routing — a fallback chain, provider preferences, and per-call cost metering across 300+ models, with no key to hold. Here it's
naive.llm.chat.
A plain LLM wrapper gives you the recommendation. None of it gives you the part a finance team actually cares about: that the agent cannot overspend and cannot skip approval, by construction.
Cost and credits
- Hotel research — ~2 credits per
hotel-searchesorhotel-infocall (Travel). - Ranking — the exact OpenRouter token cost per
llm.chat, converted at $0.50 = 1 credit;res.credits_usedis returned on every call (LLM, Credits). - Cards & approvals — no per-issuance fee; the trip is paid from the funded card, not from credits.
A full booking — research, one ranking call, a gated card issuance, and an itinerary email — lands in the low tens of credits plus the trip's actual cost.
Get started
- Get a key at usenaive.ai/developers and
npm install @usenaive-sdk/server. - Create the policy — one Account Kit with
cards.requiresApproval: trueand a connections allowlist. - Onboard employees — one tenant user each, assigned to the kit.
- Wire the loop — Travel → LLM → Cards → Connections, with Approvals on the spend.
- Hand approvals to managers — surface
approval_id, thenapproveordeny.
- SDK overview: usenaive.ai/docs/sdk/overview
- Account Kits & approvals: usenaive.ai/docs/getting-started/approvals
- Full documentation: usenaive.ai/docs
The agent researches, ranks, and books. The platform enforces caps and approvals while it does — configured per Account Kit, not promised by the model.
Why can't I just build this with an LLM and a function-calling loop?+
How does Naïve stop an agent from overspending?+
What does the Travel primitive actually return?+
Which LLM models can the agent use, and who holds the OpenRouter key?+
Is each employee's data really isolated?+
How much does a booking cost in Naïve credits?+
Can a manager deny a booking?+
Building the autonomous company infrastructure.
Issue virtual cards for your agents — a prepaid gift card or a managed virtual card — funded via checkout, capped by a spending limit, and fully audited.
Browser sessions your agents drive — navigate, act, extract — plus autonomous signup and login that vault credentials, scoped per user and watchable live.
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.
The new @usenaive-sdk/server is a single, Stripe-style TypeScript client for every Naïve primitive — email, cards, apps, LLM, vault, and more — with first-class multi-tenancy and a drop-in agent toolset. A getting-started guide.