Guide13 min read

Build an Agentic Operator for Tools with No OAuth (Where the Agent Uses a Password It Can Never Read)

Build a multi-tenant agent on Naïve for tools with no OAuth or API: the vault holds each customer's secrets, including write-only passwords it can never read.

Guide
TL;DR
  • OAuth connections cover the head of the market (Gmail, Slack, GitHub). The long tail is worse: niche SaaS that only issue a raw API key, and legacy portals with no API at all only a login form. An agent that operates them has to hold real secrets, and that is exactly what breaks a naive build.
  • Naïve ships the hard part as primitives: a per-user Credential Vault (envelope-encrypted with a managed KMS), autonomous browser signup/login that vaults credentials the model never sees, Account Kits that gate both at execution time, and human-in-the-loop approvals.
  • Two credential surfaces, one custody layer. Revealable entries (kind api_key) are secrets the agent's own code reveals at execution time to call a SaaS. Locked entries are write-only: stored once, used server-side, and never revealable back reveal returns forbidden.
  • browser.signup autonomously creates an account under the user's identity with a strong generated password, stores it in the vault, and never returns it to the agent or the model. browser.login re-authenticates from the vault later the password never crosses the agent/LLM boundary.
  • Enforcement is server-side and per tenant. A kit with vault disabled makes vault.reveal throw forbidden (primitive_disabled_by_kit); an expired session token 404s on reveal; autonomous signup is approval-gated (202 pending_approval); and per-tenant KMS EncryptionContext means one customer's wrapped key can never decrypt another's row.
  • You write the operator logic. Naïve enforces who the agent is, which secrets it may reveal, which it may only use, and what needs a human first.

Most agent demos operate the head of the integration market — Gmail, Slack, GitHub — where a clean OAuth flow exists and a platform like Naïve's connections hands you authenticated access. That is the easy 20%.

The long tail is where real back-office work lives, and it is nastier:

  • Niche SaaS that only issue a raw API key. No OAuth, no token refresh — just a key_... string the user pasted in, or that your agent generated during onboarding.
  • Legacy portals with no API at all. An insurer's broker portal, a utility billing site, a county records system, an old vendor dashboard. The only interface is a login form.

An agent that operates these has to hold real secrets — and that is exactly the part a naive build gets wrong. The moment you have more than one customer, you are on the hook for:

  • Encrypting each customer's API keys and passwords, per customer, at rest.
  • Scoping decryption so one tenant's agent is not intended to decrypt another tenant's secret.
  • Holding some secrets write-only — the agent must be able to act with a password without ever being able to read it back, so a prompt-injected instruction can't exfiltrate it.

That plumbing is the product. It is also exactly what Naïve ships as primitives — the per-user Credential Vault and the browser primitive that consumes it. This is a developer tutorial: every code block runs against the current @usenaive-sdk/server, with signatures pulled straight from the docs.

Architecture: each customer is a tenant user with one Credential Vault. Revealable api_key entries feed the agent's own code to call raw-API-key SaaS; write-only locked passwords are created and consumed by the browser primitive server-side. Every entry is envelope-encrypted with a managed KMS bound to a per-tenant EncryptionContext, and both primitives are gated by the customer's Account Kit at execution time.

What we're building

  • A multi-tenant backend where each customer gets an operator agent that works the tools OAuth can't reach.
  • Surface A — raw-API-key SaaS. The agent stores each customer's provider key in the vault and reveals it at execution time to make the call. Keys rotate on a KMS cadence; short-lived tokens carry an expiry.
  • Surface B — no-API portals. The agent autonomously signs up for an account under the customer's identity; the password is generated and vaulted write-only. Later it drives a real cloud browser to log in, navigate, and extract — without the password ever reaching the model.
  • The moat lever is custody: locked entries the agent uses but can never read back, per-tenant KMS isolation, expiring session credentials, and execution-time kit gating plus signup approval.

The primitives in play, all from the docs:

PrimitiveRole in the operatorDocs
UsersOne tenant user per customer — the isolation boundarynaive.users
Account KitsThe plan: which primitives are enabled, plus approval rulesnaive.accountKits
VaultPer-user encrypted secret custody (revealable + locked)forUser(id).vault
BrowserCloud browser + autonomous signup/login that vaults credentialsforUser(id).browser
LLMOpenRouter-backed planning across 300+ modelsforUser(id).llm
ApprovalsHuman-in-the-loop on autonomous account creationforUser(id).approvals
LogsPer-customer audit trail of every secret accessforUser(id).logs

Underneath the vault sits envelope encryption with a managed KMS — the piece you never want to hand-roll:

The vault is envelope-encrypted with a managed KMS: a per-entry data key encrypts the value with AES-256-GCM, KMS wraps the data key under a customer-managed key, and every KMS call is bound to a per-tenant EncryptionContext so one tenant's wrapped key can never decrypt another's row.

Step 0: Install and authenticate

You need a Naïve workspace key (nv_sk_...) from Studio → Settings → API keys. The SDK is server-only — keys are server secrets, 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 split is the whole design:

  • naive.* (the root client) acts on your default tenant user and exposes the control plane (naive.users, naive.accountKits).
  • naive.forUser(id).* returns the data plane (vault, browser, llm, approvals, logs) bound to one customer.

Read naive.forUser(customerId) as: "do this, as this customer, under this customer's plan, against this customer's secrets." Acme's agent is naive.forUser(acmeId); it has no path to Globex's vault.

Step 1: The two credential surfaces

The vault stores per-user secrets that aren't third-party OAuth grants — a SaaS API key, a session cookie, a KYC reference (docs). There are two kinds of entry, and the difference is the moat.

Two credential surfaces over one vault. Left: a revealable api_key entry — the agent's own code reveals it at execution time to call a raw-API-key SaaS. Right: a write-only locked password — created and consumed by the browser primitive server-side; vault.reveal on it returns forbidden.

  • Revealable entries. Stored with a kind like api_key. Your own code calls vault.reveal(key) to read the value back at execution time and put it in an Authorization header. Reveal is a POST, so the secret travels in the response body — never in a URL — and list() masks values.
  • Locked entries. Stored with locked: true. They can be used indirectly by Naïve's primitives but can never be revealed backvault.reveal returns a NaiveError with code forbidden. This is how a portal password is held: the browser primitive logs in with it server-side, but nothing in your stack (or the model's context) can read the raw value.

The put parameters, straight from the docs:

ParamTypeDefaultDescription
valuestringThe secret to encrypt and store
kindstringnoteapi_key, password, cookie, token, note, reference
lockedbooleanfalseIf true, the entry can be stored but never revealed back
expiresAtstringISO timestamp (SDK); stored as expires_at in the API. Excluded from list and 404 on reveal once expired
metadataobjectArbitrary JSON attached to the entry

Step 2: Provision a customer and its plan

An Account Kit is a reusable policy template. The vault and browser are Tools primitives, so the kit decides whether a customer's agent can use them at all — and whether autonomous account creation needs a human.

// The operator plan: custody + browser on, autonomous signup gated for a human.
const operatorKit = await naive.accountKits.create({
  name: "Operator",
  primitives_config: {
    vault: { enabled: true },
    browser: { enabled: true, requiresApproval: true }, // signup freezes for approval
    llm: { enabled: true },
  },
});

Each customer is a tenant user. Tenant users never sign into Naïve — you manage them entirely through the SDK. Point their email at a provisioned inbox so autonomous signups can receive verification mail.

const acme = await naive.users.create({
  external_id: "customer_acme",       // your stable id for the customer
  email: "ops@acme.example",          // used as the account email on signups
  label: "Acme Inc",
  account_kit_id: operatorKit.id,
});
 
const acmeClient = naive.forUser(acme.id);

Step 3: Surface A — a raw-API-key SaaS

Some SaaS never added OAuth; they hand the user a key string. Store it once, per customer, then reveal it only at execution time.

// Onboarding: the customer pasted their key for a cold-email SaaS.
await acmeClient.vault.put("instantly.api_key", "key_live_xyz", {
  kind: "api_key",
  metadata: { provider: "instantly", scope: "campaigns" },
});

When the agent needs to call that SaaS, it reveals the key at the moment of the call — the value lives in memory only for the request:

const { value: apiKey } = await acmeClient.vault.reveal("instantly.api_key");
 
const res = await fetch("https://api.instantly.ai/api/v2/campaigns", {
  headers: { Authorization: `Bearer ${apiKey}` },
});

Two custody features you get for free here:

  • Expiring tokens. For a short-lived session token, set expiresAt on vault.put(). Once it passes, the entry drops out of list() and reveal returns not_found — the agent's cue to re-mint it, with no stale secret lingering.
await acmeClient.vault.put("acme_portal.session_token", tok, {
  kind: "token",
  expiresAt: new Date(Date.now() + 30 * 60_000).toISOString(), // 30 minutes
});
  • Rotation. Re-wrap the data key under the current CMK version on a schedule — cheap, and the value ciphertext is unchanged. Pass { regenerateDek: true } for a full re-encryption.
await acmeClient.vault.rotate("instantly.api_key");                     // KMS re-wrap
await acmeClient.vault.rotate("instantly.api_key", { regenerateDek: true }); // full

To rotate the secret value itself (you generated a fresh key on the SaaS), just put the new value under the same key — put is idempotent on the key.

Step 4: Surface B — a portal with no API at all

Now the hard case: a site with no API and no OAuth, only a login form. The browser primitive gives the customer a real cloud browser and two autonomous actions — signup and login — that handle account creation and re-authentication end to end, vaulting the credential for you.

Site terms. Autonomous signup and login must comply with each site's terms of service and robots rules. Naïve provides the browser runtime; you decide which domains are allowed and which flows require human approval.

signup opens a scoped, write-enabled session, fills the registration form with the customer's identity and a strong generated password, submits, and stores { email, password } in the vault under login:<service>. The password is filled server-side via variable substitution and is never returned to the agent or sent to the model. Because it creates a real account under the customer's identity, it is approval-gated — and our kit turned that on.

const res = await acmeClient.browser.signup({
  service: "vendor-portal.example",
  url: "https://vendor-portal.example/signup",
});
 
if (isPendingApproval(res)) {
  // HTTP 202 — frozen until a human approves (res.approval_id)
  await acmeClient.approvals.approve(res.approval_id);
  // On approval the signup runs; the generated password is now vaulted write-only.
}

Later — when the browser session has expired and the agent needs the portal again — login reads the stored credential from the vault and signs back in, again without the password crossing the agent/LLM boundary:

await acmeClient.browser.login({
  service: "vendor-portal.example",
  url: "https://vendor-portal.example/login",
});

From there the agent drives a real session step by step. Sessions are scoped to an allowlist of domains (default-deny), and destructive/submit actions require allow_writes:

const s = await acmeClient.browser.createSession({
  allowed_domains: ["vendor-portal.example"],
  allow_writes: true,
});
 
await acmeClient.browser.navigate(s.session_id, "https://vendor-portal.example/invoices");
const invoices = await acmeClient.browser.extract(
  s.session_id,
  "the list of invoice numbers, dates, and amounts due",
);
await acmeClient.browser.act(s.session_id, "download the most recent invoice PDF");
 
await acmeClient.browser.closeSession(s.session_id); // always close — idle sessions bill a time floor

Two rules the docs make explicit, and they matter for safety:

  • Never put a secret in an act/extract instruction. The autonomous signup/login flow handles credentials server-side; instructions are sent to the model, credentials are not.
  • For SSO, 2FA, or CAPTCHAs, a human completes the login once in the dashboard live view and saves it as a reusable, encrypted context. Naïve stores only an opaque pointer — never the cookies. Later sessions reopen already-logged-in, gated by a human-created grant (default-deny): the agent can use a saved login but can never create the grant or revoke it.

Step 5: The moat — a secret the agent can use but cannot read

Here is the part you can't get from "a secrets manager plus a headless browser." The password the agent just used to log into the portal is locked in the vault. Try to read it back:

try {
  await acmeClient.vault.reveal("login:vendor-portal.example");
} catch (err) {
  if (err instanceof NaiveError && err.code === "forbidden") {
    // Locked entries can be used, never revealed. The raw password never leaves KMS.
    console.log(err.hint);
  }
}

The other two walls are just as hard, and all three run inside Naïve, at execution time — not as a check you remembered to write:

  • Expired secret → not_found. A session token past its expiresAt 404s on reveal and is excluded from list(). There is no window where a stale secret leaks.
try {
  await acmeClient.vault.reveal("acme_portal.session_token"); // after expiry
} catch (err) {
  if (err instanceof NaiveError && err.code === "not_found") {
    // Re-mint the token; nothing stale is served.
  }
}
  • Kit disables the primitive → forbidden. Custody itself is a plan feature. Put a customer on a kit with vault: { enabled: false } and every vault call fails server-side, before it ever touches KMS:
const readOnlyKit = await naive.accountKits.create({
  name: "Suspended",
  primitives_config: { vault: { enabled: false }, browser: { enabled: false } },
});
await naive.accountKits.assignUser(readOnlyKit.id, acme.id);
 
try {
  await acmeClient.vault.reveal("instantly.api_key");
} catch (err) {
  if (err instanceof NaiveError && err.code === "forbidden") {
    console.log(err.hint); // "primitive_disabled_by_kit"
  }
}
  • Autonomous account creation → 202 pending_approval. As in Step 4, browser.signup freezes for a human when the kit gates it — it does not throw (errors). Granting the agent the power to create a real account under the customer's identity is the moment a person signs off.

So the enforcement mechanisms compose:

  • locked custody → the agent acts with a secret it can never exfiltrate. Hard wall, no human, instant.
  • expiresAt stale secrets 404 instead of leaking.
  • Kit gate → custody is a plan feature toggled with one config edit, applied to every customer on that tier on the next call.
  • Approval gate → high-consequence account creation freezes until a human approves.

Step 6: Why the isolation actually holds

Custody claims are only worth the crypto behind them. Naïve's vault encryption is envelope encryption with a managed KMS:

  • A per-entry data key (DEK) is generated by KMS GenerateDataKey.
  • The value is encrypted locally with AES-256-GCM (nonce(12) || tag(16) || ciphertext).
  • KMS returns the DEK already wrapped by the shared customer-managed key (CMK). Naïve stores the ciphertext, the wrapped_dek, and the kek_id, and zeroizes the plaintext DEK immediately.

The line that makes multi-tenant safe:

  • Every KMS call passes EncryptionContext = { company_id, tenant_user_id }, and KMS verifies it on decrypt. A wrapped_dek stolen from one tenant's row cannot decrypt another tenant's data — even under the same CMK. The IAM policy scopes KMS actions to that one key and requires the tenant context, so a request that omits it is rejected before it reaches KMS.

That is the difference between "we encrypt secrets" and "one customer's agent is cryptographically unable to read another customer's secret" — and it is enforced by the key management layer, not your code.

Step 7: The per-customer audit trail

Every primitive write and secret access emits an activity event scoped to the tenant user. Pull one customer's history — there's no way to accidentally read another tenant's events (docs):

const { events } = await acmeClient.logs.query({ limit: 50 });
// vault.put / vault.reveal / browser.login events for Acme only — who accessed
// which secret, when, and the result — useful for internal audits, not a substitute for a formal SOC 2 program on its own.

Filter by action (e.g. vault.put) to answer "every time this customer's key was read this month" without building a logging pipeline.

What you didn't build

Count the infrastructure you skipped — the part that is normally the whole product:

  • Per-customer secret encryption with a managed KMS, per-entry data keys, and zeroized plaintext. (Vault + Vault encryption.)
  • Write-only custody so the agent can act with a password it can never read back. (locked entries.)
  • Cryptographic tenant isolation so Acme's agent can never decrypt Globex's secret. (Per-tenant KMS EncryptionContext.)
  • Autonomous account creation and re-login on sites with no API, with the password never reaching the model. (Browser signup/login.)
  • A human-in-the-loop gate on creating real accounts, and human-only grants for SSO/2FA saved logins. (Approvals + saved-login grants.)
  • A per-customer audit log of every secret access. (Logs.)

You wrote the operator logic. Naïve enforced who the agent is, which secrets it may reveal, which it may only use, and what needs a human first — at execution time, server-side, per tenant.

Ship it

  • Model your customers as tenant users and give them an Operator Account Kit with vault and browser enabled.
  • Store raw-API-key SaaS secrets as revealable entries and reveal them at execution time; carry an expiresAt on short-lived tokens and rotate on a cadence.
  • Let the browser primitive autonomously sign up and log in on no-API portals — the password is vaulted locked, and even you can't read it back.
  • Trust the API, not your prompt, to stop a reveal the plan doesn't allow.

Start from the Vault guide, the Vault encryption architecture, and the Browser guide. Get a workspace key in Naïve Studio and build the operator for the 80% of tools OAuth never reached.

Frequently Asked Questions
Why can't I just build this with a secrets manager plus a headless browser?+
You can wire a headless browser and stuff secrets into environment variables in an afternoon. The product breaks on custody and isolation. Each of your customers has their own raw API keys and their own portal logins; you have to encrypt them per customer, scope decryption to that tenant's EncryptionContext, and — the part general-purpose secret managers do not give you — hold some secrets write-only so the agent can act with them but can never exfiltrate them, even under prompt injection. Naïve ships that as the Credential Vault: per-user, envelope-encrypted with a managed KMS bound to a per-tenant EncryptionContext, with locked entries that store but never reveal. The browser primitive then consumes those locked credentials server-side so the password never reaches the model.
What is the difference between a revealable and a locked vault entry?+
A normal entry (e.g. kind api_key) can be read back with vault.reveal — you use this for a raw SaaS API key your own code needs to put in an Authorization header at execution time. A locked entry (locked: true) can be stored and used indirectly by Naïve's own primitives but can never be revealed back: vault.reveal on it returns a NaiveError with code forbidden. That is exactly what you want for a portal password — the browser primitive logs in with it server-side, but no operator, no log line, and no LLM prompt can ever read the raw value.
How does the agent log into a site that has no API and no OAuth?+
The browser primitive gives each tenant user a real cloud browser scoped to an allowlist of domains. Its autonomous signup action opens a write-enabled session, fills the registration form with the user's identity and a strong generated password, submits, and stores { email, password } in that user's vault under the key login:<service>. The password is filled server-side via variable substitution and is never returned to the agent or sent to the model. Later, browser.login reads the stored credential from the vault and signs back in — again without the password crossing the agent/LLM boundary. For sites needing SSO, 2FA, or a CAPTCHA, a human completes the login once in the dashboard live view and saves it as a reusable context that the agent can use but never create or revoke.
What does execution-time permission enforcement mean for the vault?+
Both the vault and the browser are Account Kit primitives, so the kit decides — when the agent acts, inside Naïve — whether the call is allowed at all. If a user's kit has vault disabled, vault.put and vault.reveal throw a NaiveError with code forbidden (hint primitive_disabled_by_kit) — the call never touches KMS. If autonomous signup is approval-gated in the kit, browser.signup returns 202 pending_approval and freezes until a human approves. Revealing a locked entry returns forbidden; revealing an expired entry returns not_found. None of these are checks you remember to write in your code path — they are enforced server-side, so a prompt-injected instruction cannot talk its way past them.
How are the secrets actually encrypted?+
Each entry uses envelope encryption backed by a managed KMS. A per-entry data key (DEK) is generated by KMS GenerateDataKey; the value is encrypted locally with AES-256-GCM (blob layout nonce(12) || tag(16) || ciphertext); KMS returns the DEK already wrapped by the shared customer-managed key (CMK). Naïve stores the ciphertext, the wrapped DEK, and the key id, and zeroizes the plaintext DEK immediately. Every KMS call passes EncryptionContext = { company_id, tenant_user_id }, which KMS verifies on decrypt — so a wrapped DEK stolen from one tenant's row cannot decrypt another tenant's data, even under the same CMK. See the Vault encryption architecture doc for the full model.
How do I rotate a stored credential?+
vault.rotate re-wraps the entry's data key under the current CMK version with KMS ReEncrypt — the value ciphertext is unchanged, so it is cheap and good for a scheduled key-rotation cadence. Passing { regenerateDek: true } does a full DEK regeneration plus value re-encryption. To rotate the actual secret value (e.g. you generated a fresh API key on the SaaS), just vault.put the new value under the same key — put is idempotent on the key, so it replaces the entry with a fresh DEK and ciphertext.
Do my customers ever sign into Naïve?+
No. Tenant users are not auth subjects — they never sign in, and you manage them entirely through the API/SDK/CLI. Your product's operator uses one Naïve workspace key (nv_sk_...) and scopes every call per customer with naive.forUser(customerId). The only human-facing surface is the browser dashboard live view, which a customer's admin uses once to complete an SSO/2FA login for a saved-login context.
NT
Naïve Team

Building the autonomous company infrastructure.

Keep reading