@absol-labs/agent 0.8.0 → 0.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/discovery/registry.d.ts +110 -305
- package/dist/discovery/registry.d.ts.map +1 -1
- package/dist/discovery/registry.js +141 -318
- package/dist/discovery/registry.js.map +1 -1
- package/dist/frameworks/agentkit.d.ts.map +1 -1
- package/dist/frameworks/agentkit.js +23 -6
- package/dist/frameworks/agentkit.js.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/dist/wallet/cdp-sdk.d.ts +23 -0
- package/dist/wallet/cdp-sdk.d.ts.map +1 -0
- package/dist/wallet/cdp-sdk.js +27 -0
- package/dist/wallet/cdp-sdk.js.map +1 -0
- package/dist/wallet/provider.d.ts +1 -1
- package/dist/wallet/provider.d.ts.map +1 -1
- package/dist/wallet/provider.js +8 -3
- package/dist/wallet/provider.js.map +1 -1
- package/dist/zktls/reclaim-js-sdk.d.ts +24 -0
- package/dist/zktls/reclaim-js-sdk.d.ts.map +1 -0
- package/dist/zktls/reclaim-js-sdk.js +29 -0
- package/dist/zktls/reclaim-js-sdk.js.map +1 -0
- package/dist/zktls/reclaim.d.ts +14 -2
- package/dist/zktls/reclaim.d.ts.map +1 -1
- package/dist/zktls/reclaim.js +29 -6
- package/dist/zktls/reclaim.js.map +1 -1
- package/dist/zktls/t2-delivery-proof.d.ts +8 -1
- package/dist/zktls/t2-delivery-proof.d.ts.map +1 -1
- package/dist/zktls/t2-delivery-proof.js +22 -6
- package/dist/zktls/t2-delivery-proof.js.map +1 -1
- package/docs/agent-layer.md +150 -0
- package/docs/autonomous-privy-wallet.md +133 -0
- package/docs/crewai.md +70 -0
- package/docs/eliza.md +109 -0
- package/docs/langchain.md +63 -0
- package/docs/mcp-hosted.md +137 -0
- package/docs/privy-embedded-wallet.md +102 -0
- package/docs/quickstart.md +370 -0
- package/docs/threat-model.md +160 -0
- package/package.json +19 -6
- package/src/discovery/registry.ts +242 -414
- package/src/frameworks/agentkit.ts +24 -5
- package/src/index.ts +5 -0
- package/src/wallet/cdp-sdk.ts +33 -0
- package/src/wallet/provider.ts +16 -9
- package/src/zktls/reclaim-js-sdk.ts +50 -0
- package/src/zktls/reclaim.ts +57 -23
- package/src/zktls/t2-delivery-proof.ts +28 -10
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
# Hosted MCP over Streamable HTTP
|
|
2
|
+
|
|
3
|
+
The agent lane ships two MCP transports over the **same** tool set
|
|
4
|
+
(`hire_verified_service`, `check_stream_status`, `reclaim_unspent`, `list_streams`,
|
|
5
|
+
and the optional `prove_https_response`):
|
|
6
|
+
|
|
7
|
+
- **stdio** (`src/mcp/stdio.ts`, `pnpm mcp:stdio`) — local dev, one process per
|
|
8
|
+
caller. Unchanged.
|
|
9
|
+
- **Streamable HTTP** (`src/mcp/http.ts` + `src/mcp/http-server.ts`,
|
|
10
|
+
`pnpm mcp:http`) — a hosted, **multi-tenant**, per-caller-authenticated
|
|
11
|
+
endpoint suitable for deployment. This document covers the HTTP transport.
|
|
12
|
+
|
|
13
|
+
The HTTP transport uses the MCP SDK's `StreamableHTTPServerTransport` (the
|
|
14
|
+
current remote transport; the deprecated HTTP+SSE transport is not used). It
|
|
15
|
+
speaks the standard Streamable HTTP session contract: `POST /mcp` for JSON-RPC
|
|
16
|
+
requests (an `initialize` opens a session and the server returns an
|
|
17
|
+
`Mcp-Session-Id` header), `GET /mcp` for the server→client SSE stream, and
|
|
18
|
+
`DELETE /mcp` to end a session — all carrying the `Mcp-Session-Id` header once a
|
|
19
|
+
session exists.
|
|
20
|
+
|
|
21
|
+
## Security model (fail-closed)
|
|
22
|
+
|
|
23
|
+
- **Per-caller auth before anything runs.** Every request must carry
|
|
24
|
+
`Authorization: Bearer <token>`. A missing or invalid token is rejected with
|
|
25
|
+
`401` **before** any MCP session is created or any tool executes — so an
|
|
26
|
+
unauthenticated request can never move funds or read another caller's state.
|
|
27
|
+
- **Constant-time token comparison.** Tokens are compared via SHA-256 +
|
|
28
|
+
`crypto.timingSafeEqual` across all configured tenants with no early exit;
|
|
29
|
+
tokens are never logged.
|
|
30
|
+
- **Tenant isolation.** Each valid token maps to its **own** tenant context —
|
|
31
|
+
its own wallet-backed agent client and its own stream registry. One caller
|
|
32
|
+
can never drive another caller's funds or observe another caller's streams.
|
|
33
|
+
MCP sessions are bound to the tenant that opened them: presenting another
|
|
34
|
+
tenant's `Mcp-Session-Id` is rejected with `403`; an unknown session id is
|
|
35
|
+
`404`.
|
|
36
|
+
- **Refuses to run open.** If no tenants/tokens are configured, the server
|
|
37
|
+
throws before binding — it will never serve an unauthenticated endpoint.
|
|
38
|
+
- **No stack traces leak.** Errors are returned as generic JSON-RPC error
|
|
39
|
+
envelopes; internal detail stays server-side.
|
|
40
|
+
- **CORS** is off by default (same-origin / non-browser clients). Set
|
|
41
|
+
`METRIK_MCP_ALLOWED_ORIGINS` to opt specific browser origins in.
|
|
42
|
+
|
|
43
|
+
## Environment contract
|
|
44
|
+
|
|
45
|
+
| Variable | Required | Default | Meaning |
|
|
46
|
+
| ---------------------------- | -------- | --------- | ---------------------------------------------------------------------- |
|
|
47
|
+
| `METRIK_MCP_TENANTS` | **yes** | — | JSON array of tenant configs (below). Empty/missing ⇒ refuse to start. |
|
|
48
|
+
| `PORT` / `METRIK_MCP_PORT` | no | `8080` | Listen port. |
|
|
49
|
+
| `METRIK_MCP_HOST` | no | `0.0.0.0` | Bind host. |
|
|
50
|
+
| `METRIK_MCP_PATH` | no | `/mcp` | MCP endpoint path. |
|
|
51
|
+
| `METRIK_MCP_ALLOWED_ORIGINS` | no | _(none)_ | Comma-separated CORS origins, or `*`. |
|
|
52
|
+
|
|
53
|
+
Each entry in `METRIK_MCP_TENANTS` is:
|
|
54
|
+
|
|
55
|
+
```jsonc
|
|
56
|
+
{
|
|
57
|
+
"id": "acme", // stable tenant id (routing/logging only)
|
|
58
|
+
"token": "<secret-bearer>", // the caller's bearer token — never logged
|
|
59
|
+
"env": {
|
|
60
|
+
// optional: layered over the process env to
|
|
61
|
+
"METRIK_AGENT_CDP_OWNER_NAME": "acme-agent", // build THIS tenant's wallet
|
|
62
|
+
},
|
|
63
|
+
}
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
A tenant's `env` is merged over the process environment and used to build that
|
|
67
|
+
tenant's isolated, wallet-backed agent client via the same
|
|
68
|
+
`METRIK_AGENT_*` / wallet / Reclaim variables the stdio server uses
|
|
69
|
+
(`METRIK_AGENT_RPC_URL`, `METRIK_AGENT_ESCROW`, `METRIK_AGENT_USDC`,
|
|
70
|
+
`METRIK_AGENT_PRIVATE_KEY` **or** the `CDP_*` wallet vars, etc. — see
|
|
71
|
+
`src/mcp/server.ts` and `src/wallet/provider.ts`). Tenants that share the same
|
|
72
|
+
network but need distinct wallets override only the wallet vars in their `env`.
|
|
73
|
+
|
|
74
|
+
> Provide tokens and wallet secrets through your host's secret manager / env UI.
|
|
75
|
+
> Never commit them, and never bake them into the image.
|
|
76
|
+
|
|
77
|
+
## Run it
|
|
78
|
+
|
|
79
|
+
```bash
|
|
80
|
+
export METRIK_AGENT_RPC_URL="https://base-sepolia.example/rpc"
|
|
81
|
+
export METRIK_AGENT_ESCROW="0x21948a5E6AE8d9A3D1050791AB6138657Fb54286"
|
|
82
|
+
export METRIK_AGENT_USDC="0x036CbD53842c5426634e7929541eC2318f3dCF7e"
|
|
83
|
+
# ... wallet vars (private key or CDP) ...
|
|
84
|
+
export METRIK_MCP_TENANTS='[{"id":"acme","token":"REPLACE_WITH_SECRET"}]'
|
|
85
|
+
export PORT=8080
|
|
86
|
+
|
|
87
|
+
pnpm mcp:http # dev (tsx)
|
|
88
|
+
# or, from a build:
|
|
89
|
+
node dist/mcp/http-server.js
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
The process binds `0.0.0.0:$PORT` and logs only its address (never secrets).
|
|
93
|
+
`SIGINT`/`SIGTERM` trigger a graceful shutdown of all live sessions.
|
|
94
|
+
|
|
95
|
+
## Connect an MCP client
|
|
96
|
+
|
|
97
|
+
Point any Streamable HTTP MCP client at `http(s)://<host>:<port>/mcp` with the
|
|
98
|
+
tenant's bearer token. With the TypeScript SDK:
|
|
99
|
+
|
|
100
|
+
```ts
|
|
101
|
+
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
|
102
|
+
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
|
|
103
|
+
|
|
104
|
+
const transport = new StreamableHTTPClientTransport(
|
|
105
|
+
new URL("https://mcp.example.com/mcp"),
|
|
106
|
+
{
|
|
107
|
+
requestInit: {
|
|
108
|
+
headers: { Authorization: `Bearer ${process.env.METRIK_MCP_TOKEN}` },
|
|
109
|
+
},
|
|
110
|
+
},
|
|
111
|
+
);
|
|
112
|
+
const client = new Client({ name: "my-agent", version: "1.0.0" });
|
|
113
|
+
await client.connect(transport);
|
|
114
|
+
await client.listTools();
|
|
115
|
+
await client.callTool({ name: "list_streams", arguments: {} });
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
Claude Desktop / other config-driven clients: add a remote MCP server pointing
|
|
119
|
+
at the `/mcp` URL and set the `Authorization: Bearer <token>` header. The
|
|
120
|
+
transport handles the session id, POST/GET/DELETE lifecycle automatically.
|
|
121
|
+
|
|
122
|
+
## Programmatic embedding
|
|
123
|
+
|
|
124
|
+
The transport is also exported from the package root for hosting inside another
|
|
125
|
+
service:
|
|
126
|
+
|
|
127
|
+
- `createHostedMcpHttpServer({ resolver, path?, allowedOrigins? })` — build a
|
|
128
|
+
Node `http.Server` around a `TenantResolver`; `listen(port, host)` /
|
|
129
|
+
`close()`.
|
|
130
|
+
- `StaticTenantResolver` — constant-time, in-memory resolver over a list of
|
|
131
|
+
`HostedMcpTenant`s.
|
|
132
|
+
- `parseHostedMcpServerConfig(env)` / `createHostedMcpTenants(config)` /
|
|
133
|
+
`startHostedMcpHttpServerFromEnv(env)` — the env-driven wiring used by
|
|
134
|
+
`pnpm mcp:http`.
|
|
135
|
+
|
|
136
|
+
Supply your own `TenantResolver` to source tokens/tenants from a database or a
|
|
137
|
+
secrets service instead of env.
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
# Privy embedded-wallet integration
|
|
2
|
+
|
|
3
|
+
`@absol-labs/agent` provides a small, provider-neutral adapter for a
|
|
4
|
+
user-owned Privy embedded **EOA**. The site or host application must first
|
|
5
|
+
authenticate the user with Privy's client SDK and obtain that user's EIP-1193
|
|
6
|
+
wallet provider. The agent package does not authenticate users, create wallets
|
|
7
|
+
from an app id alone, receive an app secret, or persist key material.
|
|
8
|
+
|
|
9
|
+
```ts
|
|
10
|
+
import { http } from "viem";
|
|
11
|
+
import { baseSepolia } from "viem/chains";
|
|
12
|
+
import {
|
|
13
|
+
createWalletBackedAgentClient,
|
|
14
|
+
type PrivyEip1193Provider,
|
|
15
|
+
} from "@absol-labs/agent";
|
|
16
|
+
|
|
17
|
+
// The authenticated Privy wallet/session is created by the host application.
|
|
18
|
+
const provider: PrivyEip1193Provider =
|
|
19
|
+
await host.getAuthenticatedPrivyProvider();
|
|
20
|
+
const address = await host.getAuthenticatedPrivyAddress();
|
|
21
|
+
|
|
22
|
+
const { wallet, agentClient } = await createWalletBackedAgentClient(
|
|
23
|
+
{
|
|
24
|
+
chain: baseSepolia,
|
|
25
|
+
// The Privy mode replaces this transport with the authenticated provider
|
|
26
|
+
// so writes use eth_sendTransaction. It remains the default for other modes.
|
|
27
|
+
transport: http(process.env.BASE_SEPOLIA_RPC_URL!),
|
|
28
|
+
escrow: "0x…",
|
|
29
|
+
usdc: "0x…",
|
|
30
|
+
},
|
|
31
|
+
{
|
|
32
|
+
privy: {
|
|
33
|
+
appId: "your-public-privy-app-id",
|
|
34
|
+
address,
|
|
35
|
+
provider,
|
|
36
|
+
chainId: 84532,
|
|
37
|
+
},
|
|
38
|
+
},
|
|
39
|
+
);
|
|
40
|
+
|
|
41
|
+
// `wallet.account` is a viem-compatible JSON-RPC EOA. Every autonomous write
|
|
42
|
+
// still goes through the signed Metrik spend-mandate checks in agentClient.
|
|
43
|
+
await wallet.account.signMessage({ message: "user-approved test" });
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
The adapter verifies Base Sepolia (`chainId: 84532`) and the configured address
|
|
47
|
+
against `eth_chainId` and `eth_accounts` before construction and before every
|
|
48
|
+
`personal_sign` or `eth_signTypedData_v4` request. Contract writes are sent
|
|
49
|
+
through the same authenticated provider with `eth_sendTransaction`; the adapter
|
|
50
|
+
does not claim access to raw transaction signatures. A network or account switch
|
|
51
|
+
fails closed with `PrivyEmbeddedWalletError`.
|
|
52
|
+
|
|
53
|
+
## Headless external-agent session
|
|
54
|
+
|
|
55
|
+
The Metrik dApp can attach a Privy additional signer under a non-empty Privy policy,
|
|
56
|
+
collect an owner-signed Metrik spend mandate, and exchange those approvals for a
|
|
57
|
+
short-lived broker token. An external agent adapts the copied session JSON without any
|
|
58
|
+
Privy app secret or wallet key:
|
|
59
|
+
|
|
60
|
+
```ts
|
|
61
|
+
import {
|
|
62
|
+
createPrivySessionProvider,
|
|
63
|
+
createWalletBackedAgentClient,
|
|
64
|
+
} from "@absol-labs/agent";
|
|
65
|
+
|
|
66
|
+
const session = JSON.parse(process.env.METRIK_AGENT_SESSION_JSON!);
|
|
67
|
+
const provider = createPrivySessionProvider({
|
|
68
|
+
brokerUrl: session.brokerUrl,
|
|
69
|
+
sessionToken: session.sessionToken,
|
|
70
|
+
address: session.address,
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
const { agentClient } = await createWalletBackedAgentClient(sdkConfig, {
|
|
74
|
+
privy: {
|
|
75
|
+
appId: session.appId,
|
|
76
|
+
address: session.address,
|
|
77
|
+
provider,
|
|
78
|
+
chainId: session.chainId,
|
|
79
|
+
},
|
|
80
|
+
});
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
The broker permits only bounded USDC approval, V2 stream open/close/reclaim, and
|
|
84
|
+
short-lived invocation-capability signatures for an active buyer stream. It stores a
|
|
85
|
+
hash—not the plaintext—of the session token. Revoked or expired sessions fail closed.
|
|
86
|
+
It asks Privy to sponsor only the first successful write in the session.
|
|
87
|
+
|
|
88
|
+
## Boundary and limitations
|
|
89
|
+
|
|
90
|
+
- The adapter supports a user-owned Privy EOA only.
|
|
91
|
+
- It does not claim ERC-4337, EIP-1271, or smart-account support.
|
|
92
|
+
- The browser adapter alone does not provide headless access. Headless operation requires
|
|
93
|
+
the deployed broker, a real Privy additional-signer policy, and user approval in the dApp.
|
|
94
|
+
- The provider must implement the standard `eth_sendTransaction` EIP-1193 path.
|
|
95
|
+
In Privy mode, `createWalletBackedAgentClient` replaces the SDK transport with
|
|
96
|
+
that authenticated provider so a separate public RPC cannot bypass the wallet.
|
|
97
|
+
- The host must keep the Privy app secret and any authorization material
|
|
98
|
+
server-side. Only the public app id belongs in browser configuration.
|
|
99
|
+
|
|
100
|
+
Do not use an unauthenticated or arbitrary EIP-1193 provider as a substitute
|
|
101
|
+
for Privy's authenticated user wallet. Do not put a Privy app secret in
|
|
102
|
+
`PrivyEmbeddedWalletConfig`.
|
|
@@ -0,0 +1,370 @@
|
|
|
1
|
+
# Quickstart — hire a verified service in <10 minutes
|
|
2
|
+
|
|
3
|
+
Get an AI agent from zero to a **hired, oracle-verified, buyer-recoverable** service stream
|
|
4
|
+
on Base Sepolia. Every code block below uses the real, current exports of
|
|
5
|
+
`@absol-labs/agent` — no pseudocode.
|
|
6
|
+
|
|
7
|
+
> _x402 proves the payment, Metrik proves the delivery._
|
|
8
|
+
|
|
9
|
+
**What you get:** an owner-signed spend mandate caps how much/how fast/how long/with whom
|
|
10
|
+
your agent can spend; the agent escrows USDC and pays a seller **only for the delivery an
|
|
11
|
+
independent oracle proves**. In V2, failed or unproven intervals do not advance cumulative
|
|
12
|
+
entitlement. The stream remains active until the buyer closes it or it expires, and reclaim
|
|
13
|
+
follows the checkpoint finalization or escape-window rules.
|
|
14
|
+
|
|
15
|
+
Base Sepolia constants used throughout (chainId **84532**):
|
|
16
|
+
|
|
17
|
+
| Thing | Address |
|
|
18
|
+
| ------ | -------------------------------------------- |
|
|
19
|
+
| Escrow | `0x21948a5E6AE8d9A3D1050791AB6138657Fb54286` |
|
|
20
|
+
| USDC | `0x036CbD53842c5426634e7929541eC2318f3dCF7e` |
|
|
21
|
+
|
|
22
|
+
A complete, runnable version of this journey lives in
|
|
23
|
+
[`scripts/e2e-cdp.ts`](../scripts/e2e-cdp.ts) (the live CI E2E, metrik-agent#32) — treat
|
|
24
|
+
it as the working reference.
|
|
25
|
+
|
|
26
|
+
---
|
|
27
|
+
|
|
28
|
+
## 1. Install
|
|
29
|
+
|
|
30
|
+
```bash
|
|
31
|
+
pnpm add @absol-labs/agent
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
`@absol-labs/agent` and its `@absol-labs/*` dependencies are published to the **public
|
|
35
|
+
npm registry** — no `.npmrc`, scope registry, or auth token is required to install.
|
|
36
|
+
|
|
37
|
+
**Runtime:** Node **20.x** (the package pins `engines.node` to `>=20 <21`; 20.19+
|
|
38
|
+
recommended). The Coinbase AgentKit action reads decorator metadata via
|
|
39
|
+
`reflect-metadata`, which only exists in the **built** output (`tsc` emits decorator
|
|
40
|
+
metadata; `tsx`/esbuild does not) — so `import "reflect-metadata"` **before** importing
|
|
41
|
+
the package, and for the AgentKit path import from the built package, not raw `.ts`.
|
|
42
|
+
|
|
43
|
+
---
|
|
44
|
+
|
|
45
|
+
## 2. Provision a wallet
|
|
46
|
+
|
|
47
|
+
Two supported wallet sources, both resolved through `resolveAgentWallet` /
|
|
48
|
+
`parseAgentWalletEnv`. Metrik never reads or exports a private key in CDP mode.
|
|
49
|
+
|
|
50
|
+
**Injected key (fastest for testnet):**
|
|
51
|
+
|
|
52
|
+
```ts
|
|
53
|
+
import { privateKeyToAccount } from "viem/accounts";
|
|
54
|
+
import { resolveAgentWallet } from "@absol-labs/agent";
|
|
55
|
+
|
|
56
|
+
const account = privateKeyToAccount(
|
|
57
|
+
process.env.METRIK_AGENT_PRIVATE_KEY as `0x${string}`,
|
|
58
|
+
);
|
|
59
|
+
const wallet = await resolveAgentWallet({ injectedAccount: account });
|
|
60
|
+
// wallet.source === "injected"; wallet.account is a viem Account
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
**Coinbase CDP Server Wallet v2 (production, remote signing):**
|
|
64
|
+
|
|
65
|
+
```ts
|
|
66
|
+
import { CdpClient } from "@coinbase/cdp-sdk";
|
|
67
|
+
import { resolveAgentWallet, type CdpClientLike } from "@absol-labs/agent";
|
|
68
|
+
|
|
69
|
+
const cdp = new CdpClient({
|
|
70
|
+
apiKeyId: process.env.CDP_API_KEY_ID,
|
|
71
|
+
apiKeySecret: process.env.CDP_API_KEY_SECRET,
|
|
72
|
+
walletSecret: process.env.CDP_WALLET_SECRET,
|
|
73
|
+
});
|
|
74
|
+
const wallet = await resolveAgentWallet(
|
|
75
|
+
{ cdp: { ownerName: process.env.METRIK_AGENT_CDP_OWNER_NAME! } },
|
|
76
|
+
{ createCdpClient: () => cdp as unknown as CdpClientLike },
|
|
77
|
+
);
|
|
78
|
+
// under the hood: cdp.evm.getOrCreateAccount({ name: ownerName })
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
CDP env: `CDP_API_KEY_ID`, `CDP_API_KEY_SECRET`, `CDP_WALLET_SECRET`,
|
|
82
|
+
`METRIK_AGENT_CDP_OWNER_NAME` (+ optional `METRIK_AGENT_CDP_SMART_ACCOUNT_NAME`,
|
|
83
|
+
`METRIK_AGENT_CDP_CREATE_SMART_ACCOUNT`). Or set `METRIK_AGENT_PRIVATE_KEY` and let
|
|
84
|
+
`parseAgentWalletEnv(process.env)` pick the injected path automatically.
|
|
85
|
+
|
|
86
|
+
---
|
|
87
|
+
|
|
88
|
+
## 3. Sign a spend mandate
|
|
89
|
+
|
|
90
|
+
The mandate is the guardrail: caps + allowlists checked **before any transaction**,
|
|
91
|
+
fail-closed. Build the EIP-712 payload with `createSpendMandateTypedData`, sign it with
|
|
92
|
+
the wallet, then validate the shape with `signedSpendMandateSchema`.
|
|
93
|
+
|
|
94
|
+
```ts
|
|
95
|
+
import {
|
|
96
|
+
spendMandateSchema,
|
|
97
|
+
createSpendMandateTypedData,
|
|
98
|
+
signedSpendMandateSchema,
|
|
99
|
+
} from "@absol-labs/agent";
|
|
100
|
+
import { generatePrivateKey } from "viem/accounts"; // handy for a random bytes32 mandateId
|
|
101
|
+
|
|
102
|
+
const nowSeconds = Math.floor(Date.now() / 1000);
|
|
103
|
+
const account = wallet.account;
|
|
104
|
+
|
|
105
|
+
const mandate = spendMandateSchema.parse({
|
|
106
|
+
maxPerStreamUsdc: 100_000n, // 0.1 USDC (6 decimals) — max deposit per stream
|
|
107
|
+
maxTotalUsdc: 100_000n, // cumulative cap across all streams
|
|
108
|
+
maxRatePerSecondUsdc: 1n, // max accrual rate the agent may agree to
|
|
109
|
+
maxDurationSeconds: 3600, // max stream lifetime
|
|
110
|
+
allowedOperators: [account.address as `0x${string}`], // omit/empty ⇒ any operator
|
|
111
|
+
expiresAt: nowSeconds + 365 * 24 * 60 * 60,
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
const unsigned = {
|
|
115
|
+
mandateId: generatePrivateKey(), // any 32-byte hex
|
|
116
|
+
owner: account.address as `0x${string}`,
|
|
117
|
+
chainId: 84532,
|
|
118
|
+
issuedAt: nowSeconds,
|
|
119
|
+
mandate,
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
const signature = await account.signTypedData!(
|
|
123
|
+
createSpendMandateTypedData(unsigned),
|
|
124
|
+
);
|
|
125
|
+
const signedMandate = signedSpendMandateSchema.parse({
|
|
126
|
+
...unsigned,
|
|
127
|
+
signature,
|
|
128
|
+
});
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
Denials are machine-readable (`MandateDecision.reason`): `exceeds-per-stream-cap`,
|
|
132
|
+
`exceeds-total-cap`, `exceeds-rate-cap`, `exceeds-duration-cap`, `operator-not-allowed`,
|
|
133
|
+
`mandate-expired`, `mandate-revoked`, `invalid-signature`.
|
|
134
|
+
|
|
135
|
+
---
|
|
136
|
+
|
|
137
|
+
## 4. Discover a service
|
|
138
|
+
|
|
139
|
+
Before you can hire, you need a seller. `discoverServices` reads the Metrik listings
|
|
140
|
+
registry and returns typed, **verified** services — it recovers each row's operator
|
|
141
|
+
signature and drops any listing whose signature does not match its operator or whose
|
|
142
|
+
`serviceRef` is not the one derived from its signed record. It never returns an
|
|
143
|
+
unverifiable listing.
|
|
144
|
+
|
|
145
|
+
**Zero configuration**: the default read source is
|
|
146
|
+
`https://oracle.metrik.live/listings`, the oracle's public, credential-free,
|
|
147
|
+
CORS-enabled endpoint. You need no API key and no setup.
|
|
148
|
+
|
|
149
|
+
**It fails loudly.** An unreachable, erroring, or non-200 registry throws
|
|
150
|
+
`RegistryUnavailableError` rather than returning `[]`. "The marketplace was never
|
|
151
|
+
reached" and "the marketplace is empty" are different facts, and a caller that reads
|
|
152
|
+
`[]` and gives up has been misled. Catch it only if your code can genuinely handle
|
|
153
|
+
not having reached the marketplace at all.
|
|
154
|
+
|
|
155
|
+
```ts
|
|
156
|
+
import { discoverServices, RegistryUnavailableError } from "@absol-labs/agent";
|
|
157
|
+
|
|
158
|
+
let services;
|
|
159
|
+
try {
|
|
160
|
+
services = await discoverServices({ limit: 10 });
|
|
161
|
+
} catch (error) {
|
|
162
|
+
if (error instanceof RegistryUnavailableError) {
|
|
163
|
+
// The registry was never read — do NOT treat this as "no services".
|
|
164
|
+
throw error;
|
|
165
|
+
}
|
|
166
|
+
throw error;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
for (const s of services) {
|
|
170
|
+
console.log(`${s.serviceRef} → ${s.operator} @ ${s.accessUrl} [${s.access}]`);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
const target = services[0]; // pick one to hire
|
|
174
|
+
```
|
|
175
|
+
|
|
176
|
+
Each `ServiceListing` is
|
|
177
|
+
`{ serviceRef, operator, publicUrl, access, accessUrl, signed, category }`, with
|
|
178
|
+
`serviceRef`/`operator`/`publicUrl`/`accessUrl` taken from the **signed** record, never
|
|
179
|
+
from the unsigned envelope columns. Invoke against `accessUrl` — for a `gated` listing
|
|
180
|
+
that is the operator-signed gateway origin, and a gated listing with no signed access
|
|
181
|
+
URL is dropped rather than invoked against an unsigned one.
|
|
182
|
+
|
|
183
|
+
Need the registry's full answer — `registryAvailable`, `source`, tier and verification
|
|
184
|
+
summaries, and a note explaining when a non-empty source filtered to nothing? Call
|
|
185
|
+
`discoverServicesDetailed` instead; it returns the SDK result verbatim.
|
|
186
|
+
|
|
187
|
+
> The `category` filter matches only on an authenticated PostgREST read; the public
|
|
188
|
+
> endpoint serves signed records and carries no category column. Supplying `apiKey`
|
|
189
|
+
> also requires an explicit `registryUrl` (a Supabase project URL) — the public
|
|
190
|
+
> default is not a PostgREST base, so that combination is rejected rather than
|
|
191
|
+
> silently 404ing.
|
|
192
|
+
|
|
193
|
+
---
|
|
194
|
+
|
|
195
|
+
## 5. Hire a verified service
|
|
196
|
+
|
|
197
|
+
`VerifiedStreamAgentClient.openVerifiedStream` runs the mandate guard, then escrows USDC
|
|
198
|
+
and opens the metered stream through `@absol-labs/sdk`. It throws `MandateDeniedError`
|
|
199
|
+
before any transaction if the hire breaches the mandate. Feed it the `operator` +
|
|
200
|
+
`serviceRef` from a discovered service.
|
|
201
|
+
|
|
202
|
+
```ts
|
|
203
|
+
import { VerifiedStreamAgentClient } from "@absol-labs/agent";
|
|
204
|
+
import type { StreamProofClientConfig } from "@absol-labs/sdk";
|
|
205
|
+
import { baseSepolia } from "viem/chains";
|
|
206
|
+
import { http } from "viem";
|
|
207
|
+
|
|
208
|
+
const sdkConfig: StreamProofClientConfig = {
|
|
209
|
+
chain: baseSepolia,
|
|
210
|
+
transport: http("https://sepolia.base.org"),
|
|
211
|
+
account,
|
|
212
|
+
escrow: "0x21948a5E6AE8d9A3D1050791AB6138657Fb54286",
|
|
213
|
+
usdc: "0x036CbD53842c5426634e7929541eC2318f3dCF7e",
|
|
214
|
+
};
|
|
215
|
+
|
|
216
|
+
const agent = new VerifiedStreamAgentClient(sdkConfig);
|
|
217
|
+
|
|
218
|
+
const hire = await agent.openVerifiedStream({
|
|
219
|
+
operator: target.operator, // seller payout wallet from discovery
|
|
220
|
+
serviceRef: target.serviceRef, // bytes32 the oracle verifies delivery against
|
|
221
|
+
budgetUsdc: 100_000n,
|
|
222
|
+
ratePerSecondUsdc: 1n,
|
|
223
|
+
maxDurationSeconds: 3600,
|
|
224
|
+
signedMandate,
|
|
225
|
+
spentSoFarUsdc: 0n,
|
|
226
|
+
nowSeconds: Math.floor(Date.now() / 1000),
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
console.log(`stream ${hire.streamId} opened, tx=${hire.txHash}`);
|
|
230
|
+
```
|
|
231
|
+
|
|
232
|
+
Metrik is infra-agnostic (Model A): a stream targets a seller's payout wallet +
|
|
233
|
+
`serviceRef` only — there is no service-network field. Settlement is single-chain
|
|
234
|
+
on Base Sepolia.
|
|
235
|
+
|
|
236
|
+
---
|
|
237
|
+
|
|
238
|
+
## 6. Monitor
|
|
239
|
+
|
|
240
|
+
`getStreamStatus` returns the on-chain stream plus derived `claimable`/`reclaimable`:
|
|
241
|
+
|
|
242
|
+
```ts
|
|
243
|
+
const { stream, claimable, reclaimable } = await agent.getStreamStatus(
|
|
244
|
+
hire.streamId,
|
|
245
|
+
);
|
|
246
|
+
|
|
247
|
+
console.log(stream.status); // "active" | "closed" in StreamEscrowV2
|
|
248
|
+
console.log(stream.accrued); // USDC accrued for oracle-verified delivery
|
|
249
|
+
console.log(stream.deposit); // total escrowed
|
|
250
|
+
console.log(claimable); // claimable by the seller now
|
|
251
|
+
console.log(reclaimable); // reclaimable by the buyer now
|
|
252
|
+
```
|
|
253
|
+
|
|
254
|
+
Payment only ever accrues for verified seconds. In StreamEscrowV2, a checkpoint advances
|
|
255
|
+
the operator's cumulative entitlement; a failed or unproven interval does not. Failure does
|
|
256
|
+
not change the stream from active to paused: the stream stays active until buyer close or
|
|
257
|
+
expiry. The buyer reclaims after checkpoint finalization, or through the escape-window path
|
|
258
|
+
when checkpoints are unavailable.
|
|
259
|
+
|
|
260
|
+
---
|
|
261
|
+
|
|
262
|
+
## 7. Settle
|
|
263
|
+
|
|
264
|
+
**Seller** claims earned USDC (net of protocol fee); **buyer** closes and reclaims the
|
|
265
|
+
unspent balance. All three are mandate-gated.
|
|
266
|
+
|
|
267
|
+
```ts
|
|
268
|
+
const now = () => Math.floor(Date.now() / 1000);
|
|
269
|
+
const auth = { streamId: hire.streamId, signedMandate, spentSoFarUsdc: 0n };
|
|
270
|
+
|
|
271
|
+
// Seller side — claim what delivery earned:
|
|
272
|
+
await agent.claimStream({ ...auth, nowSeconds: now() });
|
|
273
|
+
|
|
274
|
+
// Buyer side — close, then reclaim every unspent cent:
|
|
275
|
+
await agent.closeStream({ ...auth, nowSeconds: now() });
|
|
276
|
+
const { reclaimResult } = await agent.reclaimStream({
|
|
277
|
+
...auth,
|
|
278
|
+
nowSeconds: now(),
|
|
279
|
+
});
|
|
280
|
+
// or in one call: agent.reclaimStream({ ...auth, nowSeconds: now(), closeFirst: true })
|
|
281
|
+
```
|
|
282
|
+
|
|
283
|
+
**Buyer-protection guarantee:** the buyer can recover unspent USDC under the checkpoint /
|
|
284
|
+
escape-window rules, and `claimed + fees + reclaimed == deposit` conserves the escrowed
|
|
285
|
+
funds exactly.
|
|
286
|
+
|
|
287
|
+
---
|
|
288
|
+
|
|
289
|
+
## 8. Four ways to integrate
|
|
290
|
+
|
|
291
|
+
Pick the surface that matches your stack — all four sit on the **same** mandate-gated
|
|
292
|
+
client and business logic.
|
|
293
|
+
|
|
294
|
+
**(a) SDK client directly** — the path used above:
|
|
295
|
+
|
|
296
|
+
```ts
|
|
297
|
+
import { VerifiedStreamAgentClient } from "@absol-labs/agent";
|
|
298
|
+
const agent = new VerifiedStreamAgentClient(sdkConfig);
|
|
299
|
+
await agent.openVerifiedStream({
|
|
300
|
+
/* ... */
|
|
301
|
+
});
|
|
302
|
+
```
|
|
303
|
+
|
|
304
|
+
**(b) Coinbase AgentKit** — exposes `discover_services`, `hire_verified_service`,
|
|
305
|
+
`check_stream_status`, `reclaim_unspent`, `close_stream` as native actions:
|
|
306
|
+
|
|
307
|
+
```ts
|
|
308
|
+
import "reflect-metadata";
|
|
309
|
+
import { createMetrikAgentKit } from "@absol-labs/agent/agentkit";
|
|
310
|
+
|
|
311
|
+
const agentKit = await createMetrikAgentKit({
|
|
312
|
+
walletProvider, // a CdpEvmWalletProvider / CdpSmartWalletProvider
|
|
313
|
+
metrik: { signedMandate, sdkConfig }, // chainId defaults to 84532
|
|
314
|
+
});
|
|
315
|
+
// then agentKit.getVercelAITools() or getLangChainTools() — or LangChain directly:
|
|
316
|
+
// import { createLangChainVerifiedStreamTools } from "@absol-labs/agent/langchain";
|
|
317
|
+
```
|
|
318
|
+
|
|
319
|
+
**(c) MCP** — local stdio for dev, hosted HTTP for deployment:
|
|
320
|
+
|
|
321
|
+
```bash
|
|
322
|
+
pnpm mcp:stdio # local: one process per caller (src/mcp/stdio.ts)
|
|
323
|
+
pnpm mcp:http # hosted: multi-tenant Streamable HTTP, per-caller bearer auth
|
|
324
|
+
```
|
|
325
|
+
|
|
326
|
+
Tools: `discover_services`, `hire_verified_service`, `check_stream_status`, `reclaim_unspent`,
|
|
327
|
+
`list_streams` (+ `prove_https_response` when Reclaim creds are set). Env is read via
|
|
328
|
+
`parseMetrikAgentEnv` (`METRIK_AGENT_RPC_URL` / `_ESCROW` / `_USDC` + wallet vars). The
|
|
329
|
+
hosted endpoint requires `Authorization: Bearer <token>` before anything runs — see
|
|
330
|
+
[`docs/mcp-hosted.md`](./mcp-hosted.md).
|
|
331
|
+
|
|
332
|
+
**Spend mandate (required).** An MCP caller (an LLM) cannot produce an EIP-712 signature,
|
|
333
|
+
so the mandate is **not** a tool argument — the server holds it, self-signed from its own
|
|
334
|
+
funding wallet and bounded by env caps you set. A fund-moving MCP server **refuses to
|
|
335
|
+
start** without an explicit spend ceiling:
|
|
336
|
+
|
|
337
|
+
```bash
|
|
338
|
+
METRIK_AGENT_MANDATE_MAX_PER_STREAM_USDC=100000000 # 100 USDC (atomic, 6dp) per stream
|
|
339
|
+
METRIK_AGENT_MANDATE_MAX_TOTAL_USDC=1000000000 # 1000 USDC cumulative across streams
|
|
340
|
+
METRIK_AGENT_MANDATE_MAX_RATE_PER_SECOND_USDC=1000000 # 1 USDC/sec ceiling
|
|
341
|
+
METRIK_AGENT_MANDATE_MAX_DURATION_SECONDS=86400 # max stream lifetime
|
|
342
|
+
METRIK_AGENT_MANDATE_ALLOWED_OPERATORS=0xabc...,0xdef... # optional CSV allowlist; omit = any
|
|
343
|
+
METRIK_AGENT_MANDATE_TTL_SECONDS=86400 # optional mandate lifetime (default 24h)
|
|
344
|
+
```
|
|
345
|
+
|
|
346
|
+
The `hire_verified_service` tool then takes only the hire terms (`operator`, `serviceRef`,
|
|
347
|
+
`budgetUsdc`, `ratePerSecondUsdc`, optional `durationSeconds`), all USDC in **atomic
|
|
348
|
+
6-decimal units**. The cumulative cap (`maxTotalUsdc`) is enforced by default; mandate
|
|
349
|
+
revocation requires wiring a revocation resolver (the server warns once on stderr if none
|
|
350
|
+
is set).
|
|
351
|
+
|
|
352
|
+
**(d) ElizaOS** — a plugin with `DISCOVER_SERVICES`, `HIRE_VERIFIED_SERVICE`,
|
|
353
|
+
`CHECK_STREAM_STATUS`, `RECLAIM_UNSPENT`, `CLOSE_STREAM` actions:
|
|
354
|
+
|
|
355
|
+
```ts
|
|
356
|
+
import { metrikElizaPlugin } from "@absol-labs/agent/eliza"; // === createMetrikElizaPlugin
|
|
357
|
+
|
|
358
|
+
const plugin = metrikElizaPlugin({ agentClient: agent }); // agent = VerifiedStreamAgentClient
|
|
359
|
+
// register `plugin` on your Eliza character — see docs/eliza.md + examples/metrik-character.ts
|
|
360
|
+
```
|
|
361
|
+
|
|
362
|
+
---
|
|
363
|
+
|
|
364
|
+
## Honesty note
|
|
365
|
+
|
|
366
|
+
Metrik proves **verified delivery** (responded, correctly-shaped, within SLA to real
|
|
367
|
+
traffic) — **not output correctness** (that the model's answer is right). Pitch and treat
|
|
368
|
+
it accordingly. The full journey, run live against the real deployed escrow on Base
|
|
369
|
+
Sepolia, is [`scripts/e2e-cdp.ts`](../scripts/e2e-cdp.ts) (`pnpm build:e2e && pnpm
|
|
370
|
+
e2e:cdp`) — the working reference for everything above.
|