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/clientNode 18+. One dependency, jose, for RS256 signing.
Then register an agent, if you haven’t already:
npx @agentvalet/registerThis 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
| Field | Required | Notes |
|---|---|---|
platform | yes | Platform id as shown in the dashboard, e.g. slack |
endpoint | yes | Path on the upstream API, e.g. /api/chat.postMessage |
scope | yes | Must match the granted scope string verbatim |
method | no | GET (default), POST, PUT, PATCH, DELETE |
data | no | Request body. Omitted entirely when undefined |
connectionId | no | Pick a specific connection when the platform has several |
reason | no | Justification shown to the approver. Normalised and capped server-side |
approvalTimeoutMs | no | Overrides 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.
| Setting | Names checked, in order |
|---|---|
| Agent id | AGENTVALET_AGENT_ID, AGENT_ID |
| Owner id | AGENTVALET_OWNER_ID, OWNER_ID |
| Proxy URL | AGENTVALET_PROXY_URL, PROXY_URL, then https://api.agentvalet.ai |
The private key is resolved in this precedence order:
AGENT_PRIVATE_KEY_B64— base64-encoded PEM. This is the container-safe oneAGENT_PRIVATE_KEY_PATH— path to a PEM fileAGENT_PRIVATE_KEY— raw multi-line PEM, or a single line with escaped newlines~/.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",
});| Option | Default | Notes |
|---|---|---|
proxyUrl | https://api.agentvalet.ai | Trailing slash is stripped for you |
timeoutMs | 15000 | Per-request network timeout |
approvalTimeoutMs | 50000 | How long call() waits on an approval. Set 0 to never wait |
approvalPollMs | 2000 | How often the client asks whether the owner has decided |
fetch | global fetch | Injectable for tests or a custom agent |
onApprovalPending | — | Called 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.
| Error | Means | What to do |
|---|---|---|
ConfigError | Missing or malformed identity or key | Raised before any network call, so a misconfigured agent fails at startup rather than mid-workflow |
AccessDeniedError | 403. No grant for this platform and scope, or policy blocked it | Recoverable. Carries .platform and .scope; call requestAccess() |
ApprovalDeniedError | A human looked at this action and said no | Terminal. Never retry this one |
ApprovalExpiredError | The request aged out server-side before anyone responded | Re-issue the call if it still matters |
ApprovalTimeoutError | You stopped waiting; the action did not | Resume with waitForApproval(approvalId) |
UpstreamError | Allowed, approved, executed, and the SaaS returned non-2xx | Carries .status and .data. This one is the platform’s problem |
NetworkError | The transport itself failed | .hint diagnoses DNS, TLS interception, firewall or timeout |
ProxyError | Any other non-2xx from the broker | Carries .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 decisionevaluate() 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
- Scopes and permissions — how grants are shaped, and why the scope string must match verbatim
- Approval flow — what an approver actually sees
- Audit and revocation — killing an agent that misbehaves
- Python client — the same API, in Python
POST /v1/actions— the raw endpoint, if you must