Guide15 min read

Build an Agentic Inbox Copilot: a Multi-Turn OpenRouter Tool-Use Loop, Bounded Per User at Execution Time

Build an agentic inbox copilot on Naïve: a multi-turn OpenRouter tool-use loop bounded per user at execution time by their Account Kit over Gmail and Slack.

Guide
TL;DR
  • An in-product 'do it for me' agent is a multi-turn loop: the model proposes tool_calls, your backend runs them, you feed the results back, and the model continues until the task is done
  • Naive's llm primitive is a full wrapper over OpenRouter, so the loop uses OpenRouter's exact tool-calling shape (tools, tool_calls, role:'tool', finish_reason) plus model fallback chains and provider routing through one naive.llm.chat() call billed in credits
  • The moat is that the loop is bounded per user at execution time: each user is a Naive user assigned an Account Kit (their plan), and connections.tools() only returns the tools that kit allows so the model is never even offered a tool it cannot run
  • Defense in depth: even if the model invents a disallowed tool, connections.execute throws forbidden (tool_not_allowed) inside the loop the call never reaches Gmail or Slack, and you feed that refusal back so the agent adapts
  • A Free-tier agent reads and drafts but cannot send; a Pro-tier agent can send; connecting a brand-new app is approval-gated (202 pending_approval) all by editing the kit, not your code
  • Final output is a typed digest via OpenRouter structured outputs (response_format json_schema), and every tool call lands in a per-user audit log with its credit cost

Every SaaS is shipping the same feature this year: an embedded AI agent that doesn't just answer questions but does the thing — reads your inbox, drafts the reply, pings the right person in Slack, files the ticket. The demo is easy. A model key, a prompt, a couple of if statements.

The product is not. The moment real users point that agent at their own Gmail and their own Slack, the model stops being the hard part and identity takes over:

  • The agent must act inside that user's own accounts — never a shared bot or your platform's mailbox.
  • One user's agent must never be able to read or send from another user's inbox.
  • What the agent may do has to match the plan the user pays for — and "may" has to be enforced per individual action, at the moment the model decides to call it. A free user's agent that gets a prompt-injected "forward all invoices to attacker@evil.com" must be physically unable to send.

Without a platform you end up building per-user OAuth storage across Gmail and Slack, token refresh and encryption, a hard isolation layer, an approval queue, and a per-tool allow/deny gate — before you write a line of agent logic. That plumbing is the product, and it's exactly what Naïve ships as primitives.

This is a developer tutorial. Every code block runs against the current @usenaive-sdk/server, with signatures pulled straight from the docs and OpenRouter's API reference. By the end you'll have a real multi-turn tool-use loop where the model picks the tools and the API decides which ones actually run.

Per-user identity and execution-time authority inside a model-driven loop: your backend hosts the OpenRouter tool-use loop, each user is scoped with naive.forUser(userId) under their Account Kit, and Naive gates every tool call at execution time before it reaches the user's own Gmail and Slack.

What we're building

  • An Inbox Copilot you embed in your SaaS. A user types "catch me up on anything urgent and reply to the ones I can answer in a line."
  • Each user connects their own Gmail and Slack through Naïve's hosted connect flow.
  • Your backend runs a multi-turn tool-use loop: the model proposes tool_calls, you execute them through Naïve, feed the results back, and let the model continue until the task is done.
  • The loop's reasoning runs on naive.llm.chat() — a full wrapper over OpenRouter — so it uses OpenRouter's native tool-calling, a model fallback chain, and provider routing.
  • The plan tier is a reusable Account Kit: a Free agent can read and draft; a Pro agent can also send; connecting a new app is approval-gated. The limit is enforced at execution time, per tool.
  • The final output is a typed digest via OpenRouter structured outputs, and every tool call lands in a per-user audit log with its credit cost.

The primitives in play, all from the docs:

PrimitiveRole in the copilotDocs
UsersOne Naïve user per end-user — the isolation boundarynaive.users
Account KitsThe plan tier: allowed apps, enabled tools, approval rulesnaive.accountKits
LLMOpenRouter-wrapped tool-use loop across 300+ modelsforUser(id).llm
ConnectionsPer-user access to Gmail, Slack, and 1,000+ appsforUser(id).connections
ApprovalsHuman-in-the-loop on connecting a new appforUser(id).approvals
LogsPer-user audit trail of every tool callforUser(id).logs

The two apps the agent drives are connected per user through Naïve's connections provider:

Each user connects their own Gmail and Slack, and the agent acts as them: Gmail exposes GMAIL_FETCH_EMAILS and GMAIL_SEND_EMAIL, Slack exposes SLACK_FIND_USER_BY_EMAIL_ADDRESS and SLACK_SEND_MESSAGE, with no shared bot in the path.

Step 0: Install and authenticate

You need a Naïve workspace key (nv_sk_...) from Studio → Settings → API keys. The SDK is server-only — your key is a server secret, never shipped to the browser.

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

Two surfaces matter, and the difference is the whole design:

  • naive.* (the root client) acts on your default user and exposes the control plane (naive.users, naive.accountKits).
  • naive.forUser(id).* returns the data plane (llm, connections, approvals, logs) bound to a specific user.

That forUser scoping is the whole game. Read it as: "do this, as this user, under this user's plan." Alice's agent is naive.forUser(aliceId); it has no path to Bob's inbox.

Step 1: Define the plan tiers as Account Kits

An Account Kit is a reusable policy template that controls which primitives are enabled (primitives_config), which apps a user may connect (connections_config.mode + toolkits), which individual tools are callable inside each app (tools.<toolkit>.enable / .disable), and which actions need human approval.

The lever for this product is the per-tool filter. The architecture docs state it precisely: "Within an allowed toolkit, restrict to specific tools with tools.<toolkit>.enable or .disable (mutually exclusive)." That is what lets the same connected Gmail be read-only on one tier and read + send on another — without reconnecting anything.

Same loop, same prompt, different kit: a Free kit runs the fetch and lookup tools but returns forbidden for GMAIL_SEND_EMAIL and SLACK_SEND_MESSAGE, while a Pro kit runs them all and gates connecting a new app for approval.

Free — read and draft, never send. Gmail and Slack are allowed, but the per-tool filter disables the send tools. The agent can triage and propose; send tools are blocked server-side by the kit.

const freeKit = await naive.accountKits.create({
  name: "Copilot — Free",
  primitives_config: { llm: { enabled: true } },
  connections_config: {
    mode: "allowlist",
    toolkits: ["gmail", "slack"],
    tools: {
      // disable is mutually exclusive with enable: everything in the toolkit
      // EXCEPT these send tools stays callable (fetch, search, find, ...).
      gmail: { disable: ["GMAIL_SEND_EMAIL", "GMAIL_REPLY_TO_THREAD"] },
      slack: { disable: ["SLACK_SEND_MESSAGE"] },
    },
  },
});

Pro — full read + write, approval on new connections. No per-tool restriction, so the send tools are callable. But connecting a new app is approval-gated: granting the agent reach into another account is the moment a human signs off.

const proKit = await naive.accountKits.create({
  name: "Copilot — Pro",
  primitives_config: { llm: { enabled: true } },
  connections_config: {
    mode: "allowlist",
    toolkits: ["gmail", "slack"],
    // no tools filter -> every tool in the allowed toolkits is callable
    requiresApproval: false,
    approvalToolkits: ["gmail", "slack"], // connecting a new account needs a human
  },
});

A few facts to internalize, all from the docs:

  • tools.<toolkit>.enable and .disable are mutually exclusive — use one per toolkit. (architecture)
  • A disabled or non-enabled tool is filtered out; calling it returns forbidden with the hint tool_not_allowed. (errors)
  • connections.connect defaults to requiring approval; approvalToolkits lets you require it for specific apps only. (architecture)

Step 2: Provision a user and connect their accounts

Each end-user is a Naïve user. Create one and assign the kit for their plan. Users you create through the API never sign into Naïve — you manage them entirely through the SDK.

const alice = await naive.users.create({
  external_id: "user_alice",       // your stable id for this user
  email: "alice@yourapp.com",
  label: "Alice — Copilot",
  account_kit_id: freeKit.id,      // starts on the Free tier
});

Now Alice authorizes her own Gmail and Slack once. connect returns a hosted redirectUrl; you send her there, and the connection becomes ACTIVE when she finishes OAuth.

const aliceClient = naive.forUser(alice.id);
 
const gmail = await aliceClient.connections.connect("gmail", {
  callbackUrl: "https://yourapp.com/oauth/callback",
});
// gmail = { toolkit, connectedAccountId, redirectUrl, status: "INITIATED" }
// Send Alice to gmail.redirectUrl. After OAuth, the connection is ACTIVE.
 
await aliceClient.connections.connect("slack", {
  callbackUrl: "https://yourapp.com/oauth/callback",
});

Confirm status cheaply from Naïve's local mirror before the agent runs:

const connected = await aliceClient.connections.connected();
// active/expired connections for Alice only — no live provider call

Step 3: Build the model's tool surface — already filtered by the kit

This is where the moat starts. Before the loop runs, list the live tools for each connected app with connections.tools(). The result is already filtered by Alice's Account Kit, so a disabled tool is simply absent — the model is never even offered a tool it can't run.

connections.tools(toolkit) returns { toolkit, tools }, where each entry carries its slug, description, and JSON-Schema input. Map each one into OpenRouter's function-tool shape, and keep a lookup from tool slug back to its toolkit so you know where to dispatch it later:

async function buildToolSurface(client: ReturnType<typeof naive.forUser>) {
  const toolkits = ["gmail", "slack"];
  const tools: any[] = [];
  const toolkitBySlug: Record<string, string> = {};
 
  for (const tk of toolkits) {
    const { tools: live } = await client.connections.tools(tk);
    for (const t of live) {
      toolkitBySlug[t.slug] = tk;
      tools.push({
        type: "function",
        function: {
          name: t.slug,                 // e.g. "GMAIL_FETCH_EMAILS"
          description: t.description,
          // connections.tools() returns each tool's JSON-Schema input; pass it through
          parameters: t.input_schema ?? { type: "object", properties: {}, additionalProperties: true },
        },
      });
    }
  }
  return { tools, toolkitBySlug };
}
  • On the Free kit, tools contains GMAIL_FETCH_EMAILS, SLACK_FIND_USER_BY_EMAIL_ADDRESS, and the rest of the read tools — but not GMAIL_SEND_EMAIL or SLACK_SEND_MESSAGE.
  • On the Pro kit, the send tools are present.
  • You never maintain a per-plan tool list in code. The kit is the source of truth, and the surface is generated from it.

This is enforcement by construction: the model can only choose from what it's shown.

Step 4: The OpenRouter tool-use loop

Now the core. naive.llm.chat() forwards OpenRouter's request body as-is, so the loop uses OpenRouter's exact tool-calling protocol:

  • You pass tools and tool_choice: "auto".
  • When the model wants to act, the response comes back with finish_reason: "tool_calls" and choices[0].message.tool_calls[] — each { id, type: "function", function: { name, arguments } }, where arguments is a JSON string.
  • You run each tool, then append the assistant message followed by one { role: "tool", tool_call_id, name, content } message per call.
  • Repeat until the model returns a normal content answer.

You also get OpenRouter's reliability controls for free: a models fallback chain and provider routing.

const SYSTEM = [
  "You are an inbox copilot acting on the user's behalf.",
  "Use the provided tools to read mail, find people, and (if allowed) reply or post to Slack.",
  "Only the tools you are given exist. If a tool call is refused, explain it and continue.",
  "When done, summarize what you read and what you did.",
].join(" ");
 
async function runInboxAgent(userId: string, userPrompt: string) {
  const client = naive.forUser(userId);
  const { tools, toolkitBySlug } = await buildToolSurface(client);
 
  const messages: any[] = [
    { role: "system", content: SYSTEM },
    { role: "user", content: userPrompt },
  ];
 
  let spent = 0;
 
  for (let turn = 0; turn < 8; turn++) {
    const res = await client.llm.chat({
      model: "anthropic/claude-sonnet-4.6",
      models: ["anthropic/claude-sonnet-4.6", "openai/gpt-5.2"], // OpenRouter fallback chain
      provider: { sort: "throughput", data_collection: "deny" }, // OpenRouter provider routing
      messages,
      tools,
      tool_choice: "auto",
    });
 
    spent += res.credits_used ?? 0;
    const choice = res.choices[0];
    messages.push(choice.message); // assistant turn (may carry tool_calls)
 
    // No tools requested -> the model is done.
    if (choice.finish_reason !== "tool_calls" || !choice.message.tool_calls?.length) {
      return { answer: choice.message.content, credits: spent };
    }
 
    // Run every requested tool and feed the results back as role:"tool" messages.
    for (const call of choice.message.tool_calls) {
      const content = await runToolCall(client, toolkitBySlug, call);
      messages.push({
        role: "tool",
        tool_call_id: call.id,
        name: call.function.name,
        content,
      });
    }
  }
 
  return { answer: "Stopped after the turn budget.", credits: spent };
}

The dispatcher maps the model's chosen tool slug back to its toolkit and runs it through Naïve. connections.execute(toolkit, toolSlug, arguments) returns { successful, error, data } (docs):

async function runToolCall(client, toolkitBySlug, call): Promise<string> {
  const slug = call.function.name;
  const args = JSON.parse(call.function.arguments || "{}");
  const toolkit = toolkitBySlug[slug];
 
  try {
    const out = await client.connections.execute(toolkit, slug, args);
    return JSON.stringify(out); // { successful, error, data }
  } catch (err) {
    // Step 5 handles the moat case here.
    throw err;
  }
}

A first run for Alice on the Free kit:

const { answer } = await runInboxAgent(
  alice.id,
  "Catch me up on anything urgent from today, and reply to the ones I can answer in one line.",
);

The model will call GMAIL_FETCH_EMAILS with arguments like { query: "is:unread newer_than:1d", max_results: 20 } (verified Gmail tool), read the results you feed back, and try to reply. That reply is where the moat shows up.

Step 5: The moat — a tool call the agent simply cannot make

Alice is on the Free kit. Two things stop a send, and they compose.

Layer 1 — it's not on the menu. GMAIL_SEND_EMAIL was filtered out of buildToolSurface, so the model was never given it. Most of the time the agent simply reports "I drafted a reply but your plan can't send it."

Layer 2 — execution-time enforcement. Suppose the model hallucinates the slug anyway, or a prompt-injected email talks it into calling a send tool. connections.execute throws a NaiveError with code forbidden before the call reaches Gmail. Catch it and feed the refusal back so the agent adapts instead of crashing:

async function runToolCall(client, toolkitBySlug, call): Promise<string> {
  const slug = call.function.name;
  const args = JSON.parse(call.function.arguments || "{}");
  const toolkit = toolkitBySlug[slug];
 
  try {
    const out = await client.connections.execute(toolkit, slug, args);
    return JSON.stringify(out);
  } catch (err) {
    if (err instanceof NaiveError && err.code === "forbidden") {
      // hint: "tool_not_allowed" — the kit filtered this tool out at execution time.
      // The send never reached Gmail. Tell the model so it can explain to the user.
      return JSON.stringify({
        successful: false,
        error: `blocked_by_plan: ${err.hint}`,
        message: "This action isn't available on the user's plan.",
      });
    }
    throw err; // real errors still propagate
  }
}

This is the part you can't get from "a model plus two SDKs":

  • The gate runs inside Naïve, at execution time — not as a check you remembered to write before calling. A prompt-injected email can't talk past it, because the policy lives in the Account Kit, not in your code path.
  • The same loop, unchanged, behaves differently per user. Move Alice to Pro and the next run sends for real:
await naive.accountKits.assignUser(proKit.id, alice.id);
// equivalently: naive.users.update(alice.id, { account_kit_id: proKit.id })
// Now, inside the loop, the model's send call executes:
await aliceClient.connections.execute("gmail", "GMAIL_SEND_EMAIL", {
  recipient_email: "lead@acme.example",
  subject: "Re: pricing",
  body: "Yes — the Pro plan covers that. Sending the contract now.",
});
// and a Slack heads-up in the user's own workspace:
await aliceClient.connections.execute("slack", "SLACK_SEND_MESSAGE", {
  channel: "#sales",
  markdown_text: "Replied to the Acme pricing thread. :white_check_mark:",
});

Authority is data, enforced server-side, and it differs per user — with no redeploy and no per-user config drift.

Step 6: The approval gate — when the agent wants a new account

Give the agent a meta-tool so it can request access to an app the user hasn't connected. Add one entry to the surface and handle it in the dispatcher:

tools.push({
  type: "function",
  function: {
    name: "connect_app",
    description: "Ask the user to connect a new third-party app already on their plan (e.g. 'gmail' or 'slack').",
    parameters: {
      type: "object",
      properties: { toolkit: { type: "string" } },
      required: ["toolkit"],
    },
  },
});
// inside runToolCall, before the connections.execute branch:
if (slug === "connect_app") {
  const toolkit = String(args.toolkit);
  // Pro kit allowlists gmail + slack only — anything else returns forbidden before approval.
  const res = await client.connections.connect(toolkit, {
    callbackUrl: "https://yourapp.com/oauth/callback",
  });
  if (isPendingApproval(res)) {
    // HTTP 202 — frozen until a human approves. NOT a thrown error.
    return JSON.stringify({
      successful: false,
      pending_approval: res.approval_id,
      message: "Connecting this app needs your approval.",
    });
  }
  return JSON.stringify({ successful: true, redirectUrl: res.redirectUrl });
}

On the Pro kit, approvalToolkits makes connecting a new app return 202 pending_approval instead of a redirect (errors). The agent relays that to the user, and your UI approves it out-of-band:

const pending = await aliceClient.approvals.list({ status: "pending" });
await aliceClient.approvals.approve(pending[0].id); // API replays the frozen connect
const a = await aliceClient.approvals.get(pending[0].id);
// a.status: "executed" -> the connect flow proceeded; redirectUrl is in the result

So the two enforcement mechanisms compose inside one loop:

  • Per-tool filter -> forbidden (tool_not_allowed) on a send the tier doesn't grant. Hard wall, no human, instant.
  • Approval gate -> 202 pending_approval on connections.connect for a new account, frozen until a person approves and the action replays.

Step 7: A typed digest with structured outputs

For the final summary you usually want structured data, not prose — to render a card, store a record, or trigger a webhook. OpenRouter structured outputs enforce a JSON Schema on the response. Pass response_format straight through naive.llm.chat():

const digest = await aliceClient.llm.chat({
  model: "openai/gpt-5.2",
  messages: [
    { role: "system", content: "Summarize the inbox session as structured JSON." },
    { role: "user", content: answer }, // the loop's final text
  ],
  response_format: {
    type: "json_schema",
    json_schema: {
      name: "inbox_digest",
      strict: true,
      schema: {
        type: "object",
        properties: {
          urgent_count: { type: "number", description: "Emails needing attention" },
          replied: { type: "array", items: { type: "string" }, description: "Subjects replied to" },
          needs_user: { type: "array", items: { type: "string" }, description: "Threads to handle manually" },
        },
        required: ["urgent_count", "replied", "needs_user"],
        additionalProperties: false,
      },
    },
  },
});
 
const parsed = JSON.parse(digest.choices[0].message.content);
// { urgent_count: 3, replied: ["Re: pricing"], needs_user: ["Contract redline"] }

Set strict: true so the model follows the schema exactly — no hallucinated fields, no parse-and-pray.

Step 8: Cost attribution and the audit trail

Every naive.llm.chat() response carries credits_used (derived from OpenRouter's returned usage.cost), so you can attribute spend per user and per turn — the loop above already accumulates it into spent. Listing models is free; the typed routes are billed in credits ($0.50 = 1 credit). (credits)

And every primitive call lands in a per-user log. Pull a user's history scoped to them — there's no way to read another user's events (docs):

const { events } = await aliceClient.logs.query({
  action: "connections.execute",
  limit: 50,
});
// which tools the agent ran, with what arguments, and the result — for Alice only

That's your support-and-compliance artifact for free: exactly which actions the agent took, in which account, at what cost.

Shortcut: skip the wiring with agentTools()

If you don't want to hand-build the tool surface, naive.forUser(id).agentTools() returns a ready meta-toolset plus a handle(name, input) dispatcher that stays Account-Kit-gated server-side (docs). It's the fastest path when you're driving the loop with the Anthropic SDK. The hand-rolled OpenRouter loop above is the right choice when you want OpenRouter's model fallback, provider routing, and structured outputs end-to-end through naive.llm.chat().

What you didn't build

Step back and count the infrastructure you skipped — the part that is normally the whole product:

  • Per-user OAuth storage for Gmail and Slack, with refresh and encryption. (Connections.)
  • Hard isolation so Alice's agent can never touch Bob's inbox. (forUser scoping.)
  • A per-tool allow/deny gate that lets the same connected app be read-only or read + send by plan — enforced mid-loop. (connections_config.tools.)
  • A human-in-the-loop queue for connecting new accounts, with frozen-then-replayed semantics. (Approvals.)
  • A per-user audit log with cost attribution. (Logs + credits_used.)

You wrote a prompt and a while loop. Naïve enforced who the agent is, which accounts it may touch, which individual tools it may run, and what needs a human first — at execution time, server-side, per user.

Ship it

  • Author your tiers once as Account Kits — the per-tool tools.<toolkit>.disable filter is the difference between an agent that drafts and one that sends.
  • Provision each end-user as a Naïve user and have them connect their own Gmail and Slack.
  • Run the loop with llm.chat({ tools }) and dispatch with connections.execute() — and trust the API, not your prompt, to stop a call the plan doesn't allow.

Start from the LLM guide, the Connections guide, and the Account Kits architecture. Get a workspace key in Naïve Studio and ship an in-product agent your users can actually trust with their inbox.

Frequently Asked Questions
Why can't I just build this with a model API and the Gmail and Slack SDKs?+
The tool-use loop itself is an afternoon of work. The hard part is identity and authority once it's a product: the agent must act inside each user's own Gmail and Slack, which means per-user OAuth storage, token refresh and encryption, hard isolation so one user's agent never touches another's mailbox, and a permission layer that decides — at the instant the model's chosen tool runs — whether this user's plan allows it. Naive ships those as primitives: users, Account Kits, per-user connections, approvals, and a per-user audit log, all enforced server-side. The model call is the easy part.
How does Naive use OpenRouter here?+
The llm primitive is a full wrapper over OpenRouter. naive.llm.chat() forwards OpenRouter's request body as-is, so you get its tool-calling interface (tools, tool_choice, the tool_calls array, role:'tool' results, normalized finish_reason), its model fallback chain (models: [...]), its provider routing preferences (provider: { sort, data_collection, ... }), and structured outputs (response_format with type json_schema). You never hold an OpenRouter key; Naive holds it and bills each call from the exact usage.cost OpenRouter returns, converted to credits.
What does 'bounded at execution time' actually mean?+
Two layers. First, the tool surface you hand the model is built from connections.tools(), which is already filtered by the user's Account Kit — a disabled tool is simply absent, so the model can't choose it. Second, if the model ever names a tool it shouldn't (hallucination or prompt injection), connections.execute throws a NaiveError with code forbidden and hint tool_not_allowed before the call reaches the third-party API. The gate lives in the kit, not in a check you remembered to write, so a malicious email in the inbox can't talk the agent past it.
How is the agent allowed to act in a user's real Gmail and Slack?+
Each user connects their own accounts once through Naive's hosted connect flow: connections.connect('gmail', { callbackUrl }) returns a redirectUrl, the user finishes OAuth, and the connection becomes ACTIVE. From then on naive.forUser(userId).connections.execute('gmail', 'GMAIL_SEND_EMAIL', { ... }) runs against that user's authenticated mailbox — no shared bot — so ownership, rate limits, and the audit trail ride on their account.
How do I give a Free user read-only access but let Pro users send?+
Author two Account Kits. The Free kit allowlists gmail and slack but uses the per-tool filter to disable the send tools (GMAIL_SEND_EMAIL, SLACK_SEND_MESSAGE); the Pro kit leaves them enabled. Assign each user the kit for their plan with naive.accountKits.assignUser(kitId, userId). Because every call is filtered by the kit at execution time, upgrading a user is a one-line reassignment — no redeploy, no per-user config to keep in sync.
Which models can the loop use, and what does it cost?+
Any of OpenRouter's 300+ models, addressed as provider/model (e.g. anthropic/claude-sonnet-4.6, openai/gpt-5.2). Set a primary in model and a fallback chain in models so the loop survives a provider outage. Billing is per-token from OpenRouter's returned usage.cost times a small markup, converted to credits ($0.50 = 1 credit); each chat response carries credits_used so you can attribute spend per user and per turn. Listing models is free.
NT
Naïve Team

Building the autonomous company infrastructure.

Keep reading