@absol-labs/agent
v0.9.1
Published
Metrik agent layer: x402 verified-streaming payments, an MCP server, framework tools, and spend mandates so AI agents can hire and pay verified services safely.
Maintainers
Readme
@absol-labs/agent
The Metrik agent layer: everything an autonomous agent needs to hire and pay for third-party services safely, where money is released only for delivery that is independently verified. Metrik is an infra-agnostic verified-service marketplace and payment rail for AI agents, settling in USDC on Base. The tagline is the design contract: x402 proves the payment, Metrik proves the delivery — and delivery, honestly scoped, is what this package makes an agent able to enforce, never "proven-correct output."
It builds on @absol-labs/sdk, which speaks
to the on-chain metered escrow. This layer wraps that client in the guardrails and
integration surfaces an agent actually needs: owner-signed spend mandates that gate every
fund move, an x402 facilitator that turns a 402 into a verified stream, an MCP server
that exposes hire/monitor/reclaim as agent tools, adapters for the major agent frameworks,
Reclaim consumer zkTLS for buyer-side delivery proofs, and non-custodial Coinbase CDP/Privy
wallets so Metrik never holds a key.
Live on Base Sepolia (chainId 84532), testnet only. App: app.metrik.live.
Docs: metrik.live. Frontdoor: github.com/Absol-Labs.
Install
pnpm add @absol-labs/agentThe package is ESM-only ("type": "module") and targets Node >=20 <21. It ships
@absol-labs/sdk, @absol-labs/shared, viem, zod, the Reclaim zkTLS SDKs, the
Coinbase CDP SDK, and the MCP SDK as dependencies. The autonomous wallet path also
installs a prebuilt native OS-keyring binding; it is
loaded only when that secure-store path is used. The agent-framework packages
(@coinbase/agentkit, @elizaos/core, @langchain/core) are optional peer
dependencies — install only the one you use.
What it does
Spend mandates are the "an agent can't run away with the wallet" guarantee. The wallet
owner signs an EIP-712 SpendMandate carrying per-stream, total, rate, and duration caps
plus an operator allowlist and expiry. Every buyer-side fund move checks the mandate first
and hard-stops on denial; MandateDecision returns a machine-readable reason. Buyer-recovery
actions (close, reclaim) verify only the signature — never expiry or caps — so a lapsed
mandate can never strand the buyer's own funds in escrow.
The x402 facilitator (VerifiedStreamX402Facilitator) turns an HTTP 402 challenge
into a real on-chain stream open instead of a one-shot payment. The agent receives a
verified-streaming requirement, signs a payload bound to its mandate, and the facilitator
opens the escrowed stream through the SDK.
The MCP server exposes discover_services, hire_verified_service,
check_stream_status, reclaim_unspent, list_streams, and — when Reclaim credentials are
configured — prove_https_response, so any MCP-native agent transacts directly. It reads
settlement config from the environment and never accepts a raw key as a tool argument.
Framework adapters put the same mandate-gated client behind AgentKit, ElizaOS, LangChain, and CrewAI, each importable from its own subpath.
Consumer zkTLS (ReclaimConsumerProofService) lets the buyer prove the exact HTTPS
response it received, bound to its stream context. This is the L2 delivery signal in
Metrik's verification stack — dispute evidence that complements, and never replaces, the
oracle's L1 observations.
Non-custodial wallets: resolveAgentWallet() accepts an injected viem signer, a
provisioned Coinbase CDP Server Wallet v2 account, or an authenticated Privy embedded EOA.
CDP mode uses Coinbase-managed remote signing, while Privy mode adapts the host application's
authenticated EIP-1193 provider; neither path reads or exports a private key. See
docs/privy-embedded-wallet.md. The Privy adapter does
not itself authenticate users. The optional scoped-session broker performs unattended
signing behind a user-approved Privy policy and signed Metrik mandate; its first write can
request Privy gas sponsorship.
For a fresh autonomous agent, the default headless path is
provisionMetrikAutonomousWallet(): it generates a P-256 authorization key locally,
stores it in the host OS credential manager, and asks the public Metrik broker to create a
policy-bound Privy EOA owned by that key. No Privy secret, OTP, browser, clipboard, or
plaintext key file is required, and every later write is signed locally over the exact
prepared Privy request. Provisioning refuses a credential store that would not survive a
host reboot — including the Linux kernel keyring a headless host silently falls back to —
and ships EncryptedFileCredentialStore as the durable headless option. See
docs/autonomous-privy-wallet.md. The authenticated
user-owned Privy flow remains available as an optional recoverable mode.
Usage
Sign and check a spend mandate
import { privateKeyToAccount } from "viem/accounts";
import {
spendMandateSchema,
signedSpendMandateSchema,
createSpendMandateTypedData,
checkMandate,
} from "@absol-labs/agent";
const owner = privateKeyToAccount(
process.env.METRIK_AGENT_PRIVATE_KEY as `0x${string}`,
);
const mandate = spendMandateSchema.parse({
maxPerStreamUsdc: 5_000_000n, // 5 USDC (6 decimals)
maxTotalUsdc: 50_000_000n,
maxRatePerSecondUsdc: 1_000n,
maxDurationSeconds: 86_400,
allowedOperators: ["0x28ea4eF61ac4cca3ed6a64dBb5b2D4be1aDC9814"],
expiresAt: 2_000_000_000,
});
const unsigned = {
mandateId: `0x${"77".repeat(32)}` as const,
owner: owner.address,
chainId: 84532,
issuedAt: Math.floor(Date.now() / 1000),
mandate,
};
const signature = await owner.signTypedData(
createSpendMandateTypedData(unsigned),
);
const signedMandate = signedSpendMandateSchema.parse({
...unsigned,
signature,
});
// Every fund-moving action gates on this. Fail-closed on any denial.
const decision = await checkMandate(
signedMandate,
{
operator: "0x28ea4eF61ac4cca3ed6a64dBb5b2D4be1aDC9814",
budgetUsdc: 2_000_000n,
ratePerSecondUsdc: 500n,
maxDurationSeconds: 3_600,
},
0n, // spentSoFarUsdc under this mandate
{ nowSeconds: Math.floor(Date.now() / 1000) },
);
if (!decision.allowed) throw new Error(`mandate denied: ${decision.reason}`);Agent wallet lifecycle: create/restore → fund → sign
An agent gets a usable wallet entirely through this package — no out-of-band key. resolveAgentWallet()
accepts an injected viem private key OR a Coinbase CDP config; the CDP path is create-or-restore:
the same ownerName always resolves to the same CDP-managed address, so re-running with the same env
restores the identical wallet instead of minting a new one.
import {
resolveAgentWallet,
parseAgentWalletEnv,
getWalletBalances,
faucetHint,
requestCdpFaucet,
} from "@absol-labs/agent";
import { MetrikClient } from "@absol-labs/sdk";
import { CdpClient } from "@coinbase/cdp-sdk";
// 1) Create or restore, from env (CDP_API_KEY_ID/CDP_API_KEY_SECRET/CDP_WALLET_SECRET +
// METRIK_AGENT_CDP_OWNER_NAME, or METRIK_AGENT_PRIVATE_KEY for an injected key).
const wallet = await resolveAgentWallet(parseAgentWalletEnv());
console.log("address:", wallet.account.address);
// 2) Read balance.
const metrik = MetrikClient.baseSepolia({ account: wallet.account });
const balances = await getWalletBalances({
publicClient: metrik.publicClient,
address: wallet.account.address,
usdc: metrik.config.usdc,
});
// 3) Fund (testnet only) — a faucet hint, or request funds programmatically for a CDP wallet.
console.log(faucetHint(84532)); // { cdpFaucetUrl, usdcFaucetUrl, ... }
if (wallet.source === "cdp" && balances.nativeWei === 0n) {
const cdp = new CdpClient({
/* same CDP_* creds */
});
await requestCdpFaucet({
cdp,
address: wallet.account.address,
token: "eth",
});
}
// 4) Sign — `wallet.account` is a plain viem `Account`; no extra wrapper needed.
const signature = await wallet.account.signTypedData?.(/* ... */);getWalletBalances and faucetHint/requestCdpFaucet are deliberately thin: they don't introduce a
second wallet system, they just fill in the "read balance" and "get testnet funds" gaps around the
existing resolveAgentWallet() / createWalletBackedAgentClient() surface. faucetHint and
requestCdpFaucet only support Base Sepolia (84532) — Metrik is testnet-only, single-chain.
See scripts/e2e-hire.ts for the full journey (create/restore wallet → fund →
hire → invoke → settle) run against real Base Sepolia infrastructure: pnpm e2e:hire (needs real CDP
creds — see .env.example).
Hire via x402: 402 challenge → signed payload → open stream
import {
VerifiedStreamX402Facilitator,
encodeX402PayloadHeader,
} from "@absol-labs/agent";
const facilitator = new VerifiedStreamX402Facilitator({
sdkConfig, // @absol-labs/sdk StreamProofClientConfig (chain, transport, account, escrow, usdc)
});
const challenge = facilitator.challenge({
operator: "0x28ea4eF61ac4cca3ed6a64dBb5b2D4be1aDC9814",
serviceRef: `0x${"ab".repeat(32)}`,
chainId: 84532,
escrow: sdkConfig.escrow,
ratePerSecondUsdc: 500n,
maxDurationSeconds: 3_600,
maxBudgetUsdc: 2_000_000n,
});
const header = encodeX402PayloadHeader({ challenge, signedMandate });
const { streamId } = await facilitator.open(header); // mandate-gated, opens on-chainThe scheme name is exported as X402_SCHEME; parseX402ChallengeJson,
parseX402PayloadHeader, and encodeX402PayloadHeader handle the wire format on both sides.
Run the MCP server
The server runs over stdio and reads its settlement config from the environment; it never asks for a raw key in a tool argument.
pnpm mcp:stdio # or: pnpm mcp:httpProgrammatically:
import {
METRIK_MCP_TOOLS,
startVerifiedStreamMcpServerStdio,
createVerifiedStreamMcpServerFromEnv,
} from "@absol-labs/agent";
console.log(METRIK_MCP_TOOLS.map((t) => t.name));
// discover_services, hire_verified_service, check_stream_status,
// reclaim_unspent, list_streams, prove_https_response
await startVerifiedStreamMcpServerStdio(); // reads process.env, connects stdio
// or, for custom transport wiring:
// const runtime = await createVerifiedStreamMcpServerFromEnv();Core env surface: METRIK_AGENT_RPC_URL, METRIK_AGENT_ESCROW, METRIK_AGENT_USDC,
METRIK_AGENT_CHAIN_ID (84532), and one wallet source — either
METRIK_AGENT_PRIVATE_KEY (injected signer) or the CDP set (CDP_API_KEY_ID,
CDP_API_KEY_SECRET, CDP_WALLET_SECRET, METRIK_AGENT_CDP_OWNER_NAME). Set
RECLAIM_APP_ID / RECLAIM_APP_SECRET to enable the prove_https_response zkTLS tool.
See docs/quickstart.md for the full list.
Closed loop: hire → invoke → (prove) → settle
Opening and funding a stream only pays for a service - it does not, on its own,
authorize the buyer to call it. The caller-auth gateway (metrik-protocol#62/#63/#64)
closes that gap: a seller runs a small reverse-proxy gateway in front of its real
service, and a buyer's SDK calls it directly with a short-lived, single-use,
stream-bound InvocationCapability - no separate credential, no manual API key.
Seller side - front any HTTP service with the reference gateway:
import {
CallerAuthGateway,
createCallerAuthGatewayServer,
} from "@absol-labs/agent";
const gateway = new CallerAuthGateway({
escrowAddress: "0x...", // the StreamEscrowV2 this service's streams settle on
rpcUrl: process.env.METRIK_GATEWAY_RPC_URL!,
serviceRef: "0x...", // this gateway's serviceRef - must match the listing
});
const server = createCallerAuthGatewayServer({
gateway,
upstreamUrl: "https://my-real-service.example.com",
});
await server.listen(8787);Or run the ready-made standalone server from env (METRIK_GATEWAY_ESCROW_ADDRESS,
METRIK_GATEWAY_RPC_URL, METRIK_GATEWAY_SERVICE_REF, METRIK_GATEWAY_UPSTREAM_URL,
optional comma-separated METRIK_GATEWAY_SERVICE_REFS migration aliases,
optional METRIK_GATEWAY_CHAIN_ID / METRIK_GATEWAY_PORT / METRIK_GATEWAY_HOST,
optional METRIK_GATEWAY_CORROBORATING_RPC_URLS /
METRIK_GATEWAY_MAX_STATE_STALENESS_SECONDS):
pnpm gatewayEvery request must carry a valid capability; every failure mode (missing header, bad
signature, wrong buyer, closed/expired/underfunded stream, replayed nonce, wrong
method/path, wrong serviceRef) is rejected with a distinct machine-readable reason
and a 402/403 - the real upstream is never touched on a rejection. Access is revoked
automatically: once a stream is closed, expired, or reclaimed, the on-chain read fails
closed with no extra bookkeeping.
Stream state is never trusted from a single unverified view. Every configured RPC view is
read per request (in parallel, at most one read each) and a request is authorized only if
every view authorizes - so a stale view cannot keep serving a stream the buyer just
closed. A view reporting a chain head older than
METRIK_GATEWAY_MAX_STATE_STALENESS_SECONDS (default 30) is rejected outright
(stream-state-stale), and because close/expiry/full-claim are irreversible on-chain,
an observed terminal state is remembered for the process lifetime - a later stale
active read can never re-authorize. Set
METRIK_GATEWAY_CORROBORATING_RPC_URLS to one or more independent providers to
collapse the post-close window to the freshest of them.
Buyer side - after hireVerifiedService/open(), call the purchased service
directly:
import { privateKeyToAccount } from "viem/accounts";
import {
createSdkInvokeStreamReader,
discoverServices,
invoke,
} from "@absol-labs/agent";
const buyer = privateKeyToAccount(
process.env.METRIK_AGENT_PRIVATE_KEY as `0x${string}`,
);
const streamReader = createSdkInvokeStreamReader({
escrowAddress: "0x...",
rpcUrl: process.env.METRIK_AGENT_RPC_URL!,
});
const { serviceRef } = await streamReader.getStreamV2(streamId);
const listing = (await discoverServices()).find(
(service) => service.serviceRef.toLowerCase() === serviceRef.toLowerCase(),
);
if (!listing) throw new Error("verified signed listing unavailable");
const { response } = await invoke(
streamId, // from hireVerifiedService()
{
method: "POST",
path: "/v1/infer",
body: JSON.stringify({ prompt: "..." }),
},
{
streamReader,
buyer,
domain: { chainId: 84532, verifyingContract: "0x..." }, // same escrow as above
listing, // verified signature + signed callerAuth.accessUrl; no manual credential/URL
},
);
console.log(await response.json());invoke() loads the stream, fails closed BEFORE any network call if it is not active,
is expired, or does not belong to the signing account, then builds and signs a
capability scoped to exactly that one method+path (short expiry, single-use nonce)
and attaches it as the x-metrik-capability header. Use capabilityFor() directly if
you only need the signed capability without the SDK also performing the fetch.
For a public T2 (consumer-zkTLS) service, invokeWithT2DeliveryProof() attaches the SAME
capability to the exact request the buyer's Reclaim attestor proves, so the
capability-authorized call IS the delivery evidence - usage and proof become one action:
import { discoverServices, invokeWithT2DeliveryProof } from "@absol-labs/agent";
const { serviceRef: t2ServiceRef } = await streamReader.getStreamV2(streamId);
const publicT2Listing = (await discoverServices()).find(
(service) => service.serviceRef.toLowerCase() === t2ServiceRef.toLowerCase(),
);
if (!publicT2Listing || publicT2Listing.access !== "public") {
throw new Error("verified public T2 listing unavailable");
}
const { t2 } = await invokeWithT2DeliveryProof(streamId, {
streamReader,
buyer,
domain: { chainId: 84532, verifyingContract: "0x..." },
request: {
url: "https://public-t2-service.example.com/v1/infer",
method: "POST",
responseMatches: [{ type: "regex", value: '"result":"(?<result>.*)"' }],
nonceInjection: { in: "query", name: "nonce" },
},
intervalIndex: 0n,
nonce: deliveryNonce, // from GET /delivery/nonce
listing: publicT2Listing,
});
// t2.body is the exact POST /delivery/proof request payload.Gated consumer-attested invocation fails closed for now: proof-target pinning does not yet distinguish the public oracle origin from a signed access gateway. Gated services use output-probe verification until that protocol support exists.
Consumer zkTLS (delivery proofs)
ReclaimConsumerProofService (and createReclaimConsumerProofServiceFromEnv) generate a
Reclaim-backed proof of the exact HTTPS response the agent consumed, verified locally
against the declared URL, method, body, match, and redaction policy before it is trusted.
VerifiedStreamX402Facilitator.proveConsumedHttpsResponse() binds that proof to the same
buyer/mandate context that opened the stream. When credentials require private headers or
cookies, use the programmatic service or the facilitator directly rather than the MCP tool,
so secrets never pass through model-visible tool arguments. Treat the result as an L2
signal: the verifier cross-checks it with L1/oracle evidence before any settlement decision.
Framework adapters
Framework adapters are intentionally not re-exported from the top-level barrel — some pull heavy optional dependency graphs — so import each from its own subpath:
import { metrikActionProvider } from "@absol-labs/agent/agentkit";
import { metrikElizaPlugin } from "@absol-labs/agent/eliza";
import { createLangChainVerifiedStreamTools } from "@absol-labs/agent/langchain";
import { createCrewAiVerifiedStreamMcpConfig } from "@absol-labs/agent/crewai";Per-framework guides: docs/langchain.md,
docs/eliza.md, docs/crewai.md.
Requirements
- Node
>=20 <21, package manager[email protected]. - ESM only — this package has no CommonJS build; consume it from an ESM context.
- Framework adapters need their matching optional peer dependency installed. Known caveat:
the AgentKit adapter pulls a heavy graph (
@coinbase/agentkit/ ZeroDev) that can break a flatnpm install; preferpnpmand import the adapter only from its@absol-labs/agent/agentkitsubpath.
Develop
corepack enable && corepack prepare [email protected] --activate
pnpm install
pnpm typecheck
pnpm test
pnpm buildThe x402 facilitator, zkTLS proof path, signed-mandate engine, MCP server, and framework
adapters are all real and tested in-repo. The *.int.test.ts integration suites deploy the
hardened StreamEscrow on a throwaway anvil node and self-skip when anvil is not on
PATH, so default CI stays green. A full hire → stream → settle walkthrough lives in
examples/hire-stream-settle.ts.
Links
- Frontdoor: github.com/Absol-Labs
- Docs: metrik.live · App: app.metrik.live
- SDK:
@absol-labs/sdk - Quickstart:
docs/quickstart.md· Design + narrative:docs/agent-layer.md· Security review:docs/threat-model.md
