Skip to Content
Frameworks & clientsNode & TypeScript

Node & TypeScript

@agentvalet/client is the non-MCP path onto the broker. A LangChain tool, a cron job, a queue worker, a plain Express service: anything that isn’t an MCP host gets the same governance the MCP server provides, as a typed SDK rather than raw HTTP.

If you are inside Claude Code, Claude Desktop, Cursor or another MCP host, you don’t need this package. Install @agentvalet/mcp-server instead and call read_platform / write_platform / delete_platform — same guarantees, no code. If you’re building an MCP server of your own and want policy enforced inside it, that’s @agentvalet/mcp-broker.

Install

npm install @agentvalet/client

Node 18+. One dependency, jose, for RS256 signing.

Then register an agent, if you haven’t already:

npx @agentvalet/register

This generates an RSA keypair on your machine, registers the public half, and writes ~/.agentvalet/agent.key. The private half never crosses the wire.

Your first call

import { AgentValet } from "@agentvalet/client"; const av = AgentValet.fromEnv(); const result = await av.call({ platform: "slack", endpoint: "/api/chat.postMessage", method: "POST", scope: "chat:write", data: { channel: "#general", text: "Deploy finished." }, });

There is no Slack token in that file, in your environment, or in the model’s context. The SDK signs a 60-second identity assertion for your agent, the broker checks the call against your grants and policy, decrypts the real credential in memory, makes the call, and writes an audit row.

call() returns the broker’s envelope, parsed — the upstream SaaS body under data, with _meta describing the call itself:

{ data: { ok: true, ts: "1723800000.000100" }, // exactly what Slack returned _meta: { capability: "agent.action", timestamp: "...", next_actions: [...], docs: "..." } }

The split is deliberate: data is the upstream payload byte-for-byte, so anything the broker wants to tell you about the call stays outside it. It’s generic, so av.call<Envelope<SlackResponse>>(...) types the result.

Call options

FieldRequiredNotes
platformyesPlatform id as shown in the dashboard, e.g. slack
endpointyesPath on the upstream API, e.g. /api/chat.postMessage
scopeyesMust match the granted scope string verbatim
methodnoGET (default), POST, PUT, PATCH, DELETE
datanoRequest body. Omitted entirely when undefined
connectionIdnoPick a specific connection when the platform has several
reasonnoJustification shown to the approver. Normalised and capped server-side
approvalTimeoutMsnoOverrides the client-level approval budget for this one call

Where it finds your identity

fromEnv() works with no arguments on a machine that has been through the register flow. Both naming conventions are accepted, and the AGENTVALET_-prefixed name wins when both are set.

SettingNames checked, in order
Agent idAGENTVALET_AGENT_ID, AGENT_ID
Owner idAGENTVALET_OWNER_ID, OWNER_ID
Proxy URLAGENTVALET_PROXY_URL, PROXY_URL, then https://api.agentvalet.ai

The private key is resolved in this precedence order:

  1. AGENT_PRIVATE_KEY_B64 — base64-encoded PEM. This is the container-safe one
  2. AGENT_PRIVATE_KEY_PATH — path to a PEM file
  3. AGENT_PRIVATE_KEY — raw multi-line PEM, or a single line with escaped newlines
  4. ~/.agentvalet/agent.key — written by the register flow

Empty strings count as unset, as do unresolved ${...} placeholders, so a blank field in a config UI fails with a clear message rather than a confusing one later. A key pasted through a secrets UI that stripped the -----BEGIN----- armour is tolerated and re-wrapped.

Configuring the client explicitly

fromEnv() takes overrides, or construct it directly:

const av = new AgentValet({ agentId: process.env.AGENT_ID!, ownerId: process.env.OWNER_ID!, privateKey: process.env.AGENT_PRIVATE_KEY!, proxyUrl: "https://api.agentvalet.ai", });
OptionDefaultNotes
proxyUrlhttps://api.agentvalet.aiTrailing slash is stripped for you
timeoutMs15000Per-request network timeout
approvalTimeoutMs50000How long call() waits on an approval. Set 0 to never wait
approvalPollMs2000How often the client asks whether the owner has decided
fetchglobal fetchInjectable for tests or a custom agent
onApprovalPendingCalled roughly every poll while an approval is outstanding

The 50-second approval budget sits deliberately under Claude Desktop’s 60-second tool timeout, so the SDK path and the MCP path give up at the same moment. If you change it, you’re changing when your agent stops waiting, not when the action expires.

Which connection served the call

When a platform has more than one connection and you don’t pin one, the broker picks the default. That’s the quiet failure mode behind a lot of confused agents: you ask for a repo, the default GitHub account doesn’t have it, you get a 404, and you conclude the repo doesn’t exist rather than that you’re on the wrong account.

So when there was more than one connection you could have used, the response says so.

On success it rides _meta, leaving data untouched:

const res = await av.call({ platform: "github", endpoint: "/repos/acme/x", scope: "repo:read" }); res._meta.connection; // { // used: "o-github", label: "Acme", defaulted: true, // others: [{ connection_id: "o-github-9f1c", label: "Personal" }], // hint: "...the target may live on another connection - retry with connection_id..." // }

On an upstream error there’s no envelope — the body comes back exactly as the platform sent it — so the same information arrives as one namespaced key, and ProxyError.body carries it:

{ "message": "Not Found", "documentation_url": "https://docs.github.com/...", "_agentvalet": { "connection": { "used": "o-github", "defaulted": true, "others": [...], "hint": "..." } } }

The upstream keys stay exactly where they were, so your existing error handling is unaffected.

It’s absent whenever there’s nothing to act on: a connection you pinned yourself, a platform with only one connection, or a lookup that failed. Treat its absence as “no alternatives”, never as “this is definitely the right account”.

Approvals are a slower call

When your policy marks a scope as needing a human, nothing about your code changes. The broker returns 202 with an approval id, the SDK enters a long-poll, and you approve from the dashboard, an email or your phone. The call then executes and call() returns its result. From your program’s point of view it simply took longer.

Use onApprovalPending if you want to say something while that happens:

const av = AgentValet.fromEnv({ onApprovalPending: ({ approvalId, elapsedMs, platform, scope }) => { console.log(`waiting ${Math.round(elapsedMs / 1000)}s on ${platform}:${scope} (${approvalId})`); }, });

When nobody answers

Past the budget you get ApprovalTimeoutError, and this is not a failure. The action is still queued server-side and will run if the owner approves. Hold the approvalId and resume later, from this process or a completely different one:

import { ApprovalTimeoutError } from "@agentvalet/client"; try { await av.call({ platform: "slack", endpoint: "/api/chat.postMessage", method: "POST", scope: "chat:write", data: message }); } catch (err) { if (err instanceof ApprovalTimeoutError) { await saveForLater(err.approvalId); // resume in another run } } // later, anywhere: const result = await av.waitForApproval(approvalId);

Resume rather than retry. Calling call() again queues a second copy of the action.

Setting approvalTimeoutMs: 0 turns every approval-gated call into an immediate ApprovalTimeoutError carrying the id, which is what you want in a serverless function that shouldn’t sit blocked for a minute.

Errors you can branch on

Governance you can’t handle in code is just an outage. Every throw from the SDK is one of these, so your agent can tell “I’m not allowed to” apart from “Slack is down” apart from “a human said no”, without string-matching an error envelope.

ErrorMeansWhat to do
ConfigErrorMissing or malformed identity or keyRaised before any network call, so a misconfigured agent fails at startup rather than mid-workflow
AccessDeniedError403. No grant for this platform and scope, or policy blocked itRecoverable. Carries .platform and .scope; call requestAccess()
ApprovalDeniedErrorA human looked at this action and said noTerminal. Never retry this one
ApprovalExpiredErrorThe request aged out server-side before anyone respondedRe-issue the call if it still matters
ApprovalTimeoutErrorYou stopped waiting; the action did notResume with waitForApproval(approvalId)
UpstreamErrorAllowed, approved, executed, and the SaaS returned non-2xxCarries .status and .data. This one is the platform’s problem
NetworkErrorThe transport itself failed.hint diagnoses DNS, TLS interception, firewall or timeout
ProxyErrorAny other non-2xx from the brokerCarries .status and .body

All of them extend AgentValetError, so one catch can separate broker problems from everything else in your process.

Asking for access you don’t have

AccessDeniedError is recoverable. requestAccess() asks an org admin to grant the scope and polls for the decision:

import { AccessDeniedError } from "@agentvalet/client"; try { await av.call({ platform: "stripe", endpoint: "/v1/refunds", method: "POST", scope: "refunds:write", data: { charge } }); } catch (err) { if (err instanceof AccessDeniedError) { const decision = await av.requestAccess({ platform: err.platform, scope: err.scope, reason: "Refund duplicate charges flagged by support", }); // decision.status is "approved" | "denied" | "pending" } }

On "approved", retry the original call(). On "pending" the request is still open — the admin hasn’t decided yet, and the returned requestToken identifies it.

See Access requests for what the admin sees.

Finding out what you’re allowed to do

Three read-only calls, none of which perform an action:

const grants = await av.listPlatforms(); // platforms + scopes actually granted const pending = await av.pendingActions(); // queued behind an approval const check = await av.evaluate("stripe", "refunds:write"); // dry-run a decision

evaluate() is worth calling before anything destructive. It tells you whether the action would be allowed, with no side effect and no audit consequence for having asked.

listPlatforms() is the deny-by-default surface: if a platform and scope aren’t in there, call() will raise AccessDeniedError.

Notes for production

Assertions live 60 seconds. The SDK signs a fresh one per request, so this is invisible to you — but it’s why any integration that pins a bearer token at construction time breaks a minute into a run. If you’re wiring AgentValet into something that takes headers once, that’s the constraint to design around.

The key is the only secret on the box. Mount it as a file and point AGENT_PRIVATE_KEY_PATH at it, or pass AGENT_PRIVATE_KEY_B64 in a 12-factor environment. Nothing else needs to travel with your agent, and no platform credential ever does.

Repeated auth failures trip the breaker. A wrong key doesn’t fail quietly forever — see Circuit breaker for what suspends an agent and how to clear it.

Next steps

Last updated on