Guide13 min read

How to Build an Agentic Corporate Travel Agent With Server-Side Policy Enforcement

Build a multi-tenant corporate travel desk with the Naïve SDK: an AI agent that researches, ranks, and books hotels, with spend capped and approvals enforced.

Guide
TL;DR
  • 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.

Architecture: an employee request flows to a Travel Desk agent scoped to one tenant user; the agent researches hotels with the Travel primitive, ranks them with the LLM primitive, and issues a per-trip virtual card whose spend ceiling is enforced by the card network. Out-of-policy card issuance freezes as 202 pending_approval until a manager approves, then the API replays it. The itinerary is sent from the employee's own connected Gmail.

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/server

Initialize 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" with toolkits: ["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 Alice

Step 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:

  1. The cap is physical. spending_limit_cents is a ceiling the card network enforces. No charge above it clears, no matter what the agent does.
  2. The issuance is gated. Because the Account Kit set cards.requiresApproval: true, the agent's cards.create call doesn't execute — it returns 202 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.

Execution-time permission enforcement: a within-policy card issuance is approved by policy and the card is issued with a hard cap equal to the trip budget; an out-of-policy issuance is frozen by the API as 202 pending_approval, a manager approves or denies, and approval replays the frozen action and records it.

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_cents

The 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-searches or hotel-info call (Travel).
  • Ranking — the exact OpenRouter token cost per llm.chat, converted at $0.50 = 1 credit; res.credits_used is 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

  1. Get a key at usenaive.ai/developers and npm install @usenaive-sdk/server.
  2. Create the policy — one Account Kit with cards.requiresApproval: true and a connections allowlist.
  3. Onboard employees — one tenant user each, assigned to the kit.
  4. Wire the loopTravelLLMCardsConnections, with Approvals on the spend.
  5. Hand approvals to managers — surface approval_id, then approve or deny.

The agent researches, ranks, and books. The platform enforces caps and approvals while it does — configured per Account Kit, not promised by the model.

Frequently Asked Questions
Why can't I just build this with an LLM and a function-calling loop?+
A model can recommend a hotel, but it cannot hold identity, money, or enforcement. To book real trips for a fleet of employees you need: a per-employee isolated identity, a real spendable card scoped and capped per trip, and a policy enforced when the agent acts — not only in a prompt. Naïve provides those as primitives — tenant users, virtual cards with a hard spending_limit_cents, and Account-Kit approval gating that freezes sensitive actions at execution time. Enforcement lives in the API and the card network; prompts alone are not sufficient.
How does Naïve stop an agent from overspending?+
Two independent layers. First, every per-trip card is issued with a spending_limit_cents ceiling that the card network enforces — no charge above it clears, regardless of what the agent does. Second, issuing or topping up a card is an approval-gated action for tenant users: the agent's call returns status 'pending_approval' with an approval_id, and the action only executes after a human approves it in the Approvals queue. Operators configure which actions are gated per Account Kit.
What does the Travel primitive actually return?+
It is a use-case view over Business Data backed by Google Hotels and TripAdvisor. Google hotel endpoints (hotel-searches, hotel-info) return live results immediately; TripAdvisor search is async (submit a task, poll tasks-ready, then retrieve). There is no dedicated /v1/travel route and no SDK sub-client — you call the documented /v1/business/... endpoints over REST (or naive business ... in the CLI). Each call costs 2 credits.
Which LLM models can the agent use, and who holds the OpenRouter key?+
The llm primitive is a full wrapper over OpenRouter — 300+ models behind one OpenAI-compatible endpoint (anthropic/claude-sonnet-4.6, openai/gpt-5.2, and more). Naïve holds the OpenRouter key server-side and bills OpenRouter's reported usage cost, converted to credits. You get OpenRouter's routing controls for free: a models[] fallback chain, a provider{} routing object, streaming, tools, and response_format for structured output.
Is each employee's data really isolated?+
Yes. Every primitive — cards, connections, vault, approvals — is scoped to a tenant user. You create one tenant user per employee and scope calls with naive.forUser(employeeId). One employee's cards, connected Gmail, and approval queue are never visible to another. A single Account Kit applies the same policy across all of them without per-user configuration.
How much does a booking cost in Naïve credits?+
The Naïve-metered cost is small and predictable: roughly 2 credits per hotel search or hotel-info call, the exact OpenRouter token cost for each ranking call (converted at $0.50 = 1 credit), and no per-trade-style fee for issuing the card. The trip itself is paid from the funded virtual card, not from credits. See usenaive.ai/docs/getting-started/credits for current rates.
Can a manager deny a booking?+
Yes. A gated action sits in the Approvals queue as 'pending'. A manager can approve it (the API replays the frozen action and records the result) or deny it with a reason. You surface the approval_id in your own UI, Slack, or email, and call approve or deny when the human decides.
NT
Naïve Team

Building the autonomous company infrastructure.

Keep reading