@agentvalet/client
v0.2.0
Published
AgentValet client SDK — call approved SaaS platforms from any Node agent. Signs the agent JWT, brokers through the AgentValet proxy, and waits out owner approvals.
Maintainers
Readme
@agentvalet/client
Call approved SaaS platforms from any Node agent. Your agent never holds the downstream credential — AgentValet signs a short-lived identity assertion, checks the call against the owner's grants and policy, injects the credential at call time, and writes an audit record.
Not in an MCP host? This is your package. If you are inside Claude Code,
Claude Desktop, Cursor, or another MCP-aware host, install
@agentvalet/mcp-server
instead and call use_platform — you get the same guarantees with no code.
Building your own MCP server and want to enforce policy inside it? That's
@agentvalet/mcp-broker.
Install
npm install @agentvalet/clientNode 18+. One dependency (jose, for RS256 signing).
Get an agent identity
npx @agentvalet/registerThis generates an RSA keypair locally — the private key never leaves your
machine — registers the public half, and writes ~/.agentvalet/agent.key.
Use it
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." },
});call() resolves to the broker's envelope — 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: "…" }
}So it's result.data.ok, not result.ok. The split is deliberate: data is the
upstream payload byte-for-byte, so anything the broker adds about the call stays
outside it.
fromEnv() reads AGENTVALET_AGENT_ID / AGENTVALET_OWNER_ID (or the bare
AGENT_ID / OWNER_ID the CLI writes) and finds the key via
AGENT_PRIVATE_KEY_B64, AGENT_PRIVATE_KEY_PATH, AGENT_PRIVATE_KEY, or
~/.agentvalet/agent.key. To wire it up explicitly:
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", // default
});Approvals are just a slower call
When the owner has marked a scope as requiring approval, the proxy holds the
action and call() waits. If the owner approves, the proxy re-runs the call and
you get the result back — from your code's point of view it simply took longer.
const av = AgentValet.fromEnv({
onApprovalPending: ({ elapsedMs }) =>
console.log(`waiting for owner… ${Math.round(elapsedMs / 1000)}s`),
});If nobody responds inside the budget (50s by default), you get an
ApprovalTimeoutError — not a failure. The action stays queued. Keep the
approvalId and resume whenever you like:
import { ApprovalTimeoutError } from "@agentvalet/client";
try {
await av.call({ platform: "stripe", endpoint: "/v1/refunds", method: "POST", scope: "charge" });
} catch (err) {
if (err instanceof ApprovalTimeoutError) {
await saveForLater(err.approvalId); // e.g. into your job queue
}
}
// …later, in another process:
const result = await av.waitForApproval(approvalId);Set approvalTimeoutMs: 0 if you never want to block — call() then throws
ApprovalTimeoutError the moment approval is required.
Errors you can branch on
Every throw is typed, so you never string-match an error envelope:
| Error | Means | What to do |
|---|---|---|
| ConfigError | Bad/missing identity or key | Fix config; thrown before any network call |
| NetworkError | Transport failed | .message diagnoses DNS / TLS / firewall / timeout |
| AccessDeniedError | No grant, or policy blocked it | requestAccess() — see below |
| ApprovalDeniedError | Owner said no | Terminal. Don't retry |
| ApprovalExpiredError | Aged out server-side | Re-issue the call |
| ApprovalTimeoutError | You stopped waiting | Resume via waitForApproval(approvalId) |
| UpstreamError | The SaaS itself returned non-2xx | .status / .data hold the upstream reply |
| ProxyError | Anything else from the broker | .status / .body |
Asking for access you don't have
Deny-by-default means a scope you were never granted returns
AccessDeniedError. Your agent can ask for it:
const { status } = await av.requestAccess({
platform: "slack",
scope: "chat:write",
reason: "Post deploy notifications to #general",
});
if (status === "approved") { /* retry the original call */ }Checking before you act
await av.listPlatforms(); // what this agent is actually granted
await av.pendingActions(); // queued behind an approval
await av.evaluate("stripe", "charge"); // dry-run the decision, no side effectevaluate() is worth a call before anything destructive — it tells you whether
the action would be allowed without performing it.
Which connection served the call
When a platform has several connections and you don't pin one, the broker uses the default. That's the quiet failure 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.
When there was more than one connection you could have used, the response says
so. On success it rides _meta, leaving data untouched:
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 is exactly what the platform
sent — so the same information arrives as one namespaced key, carried on
ProxyError.body:
{
"message": "Not Found",
"_agentvalet": { "connection": { "used": "o-github", "defaulted": true, "others": [], "hint": "…" } }
}The upstream keys stay where they were, so existing error handling is unaffected. It's absent when there's nothing to act on — a connection you pinned, a platform with one connection, or a lookup that failed — so treat its absence as "no alternatives", never as "this is definitely the right account".
One identity per subagent
If your orchestrator fans work out to subagents, don't hand them your key. Mint each one a child identity: a bearer token that is scope-attenuated, time-boxed, independently revocable, and audited under its own agent id.
const child = await av.issueChild(
[{ platform: "github", scopes: ["github:issues.read"] }],
{ name: "triage-worker", ttlSeconds: 600 },
);
// Hand the subagent the token and nothing else.
const sub = child.client(); // or AgentValet.fromBearer(child.bearerToken)
await sub.call({ platform: "github", endpoint: "/issues", scope: "github:issues.read" });The proxy attenuates every grant against what you currently hold — you cannot
delegate more than you have — and refuses the whole call if the intersection is
empty. child.granted is what it actually issued, which is not necessarily what
you asked for. Revoking or suspending the parent contains every child on its
next call.
Bounds that are enforced server-side, and that you should design around rather than discover:
| | |
|---|---|
| Delegation depth | 1 — a child cannot mint children. issueChild() on a bearer client throws ConfigError locally. |
| TTL | 60–3600s, default 900. No refresh endpoint — a longer run must re-mint from the parent. |
| Live children | 25 per parent. |
| Grants per call | 25. |
Tagging a run
sessionId tags everything a client does, so an audit reader can group one run —
or tell a parent's calls from its children's.
const av = AgentValet.fromEnv({ sessionId: `run:${runId}` });It rides the signed assertion as a session_id claim, or X-AV-Session in
bearer mode, and also reads from AGENTVALET_SESSION_ID. Attribution only —
the proxy never branches an authorization decision on it, and it is not verified
to belong to the calling agent. Don't treat it as a security boundary.
Self-hosting
Point proxyUrl at your own deployment. Everything else is identical.
License
MIT
