@fangorn-network/westmarch 0.0.1-dev
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/LICENSE.md +20 -0
- package/README.md +280 -0
- package/consume/apps.js +111 -0
- package/consume/corpora.js +297 -0
- package/consume/directory.js +262 -0
- package/consume/embed.js +251 -0
- package/consume/embed.worker.js +26 -0
- package/consume/lint.js +500 -0
- package/consume/mcp.js +381 -0
- package/consume/rank.js +218 -0
- package/consume/roles.js +556 -0
- package/consume/shard.js +726 -0
- package/consume/steam.js +341 -0
- package/consume/taste-doc.js +342 -0
- package/consume/taste.js +350 -0
- package/consume/tools.js +358 -0
- package/consume/ui.js +724 -0
- package/docs/app-to-agent.md +351 -0
- package/package.json +70 -0
- package/publish/agent-card.js +134 -0
- package/publish/cli.js +336 -0
- package/publish/cohort.js +349 -0
- package/publish/demand.js +537 -0
- package/publish/enrich.js +211 -0
- package/publish/envelope.js +185 -0
- package/publish/graph.js +307 -0
- package/publish/manifest.js +142 -0
- package/publish/reactions.js +343 -0
- package/publish/settle.js +165 -0
- package/publish/terms.js +200 -0
- package/publish/vault.js +162 -0
|
@@ -0,0 +1,351 @@
|
|
|
1
|
+
# From app to agent: registering and discovering a runtime-free backend
|
|
2
|
+
|
|
3
|
+
This walks one static site from nothing to "any agent can find it and use it":
|
|
4
|
+
|
|
5
|
+
1. The site exposes WebMCP tools and an A2A agent card.
|
|
6
|
+
2. You claim a Fangorn app and point it at that card.
|
|
7
|
+
3. You register the card as an ERC-8004 agent, which makes it publicly searchable.
|
|
8
|
+
4. Someone who has never heard of it discovers it, verifies it, and drives it.
|
|
9
|
+
|
|
10
|
+
It runs on Arbitrum Sepolia. Steps 1–5 and 7 were run against the live chain (step 6's
|
|
11
|
+
publish commands are from the CLI's help and have not been run for Kingsfoil yet); [Kingsfoil](#reference-kingsfoil) is the worked example, and its real values are
|
|
12
|
+
at the end.
|
|
13
|
+
|
|
14
|
+
```
|
|
15
|
+
ERC-8004 identity registry ──(A2A endpoint)──┐
|
|
16
|
+
(searchable on 8004scan) ▼
|
|
17
|
+
https://your.site/.well-known/agent-card.json
|
|
18
|
+
Fangorn AppRegistry ──(agent_uri)────────────▲ │
|
|
19
|
+
AppAgentChanged log = the app directory │ card names appId, fromBlock,
|
|
20
|
+
│ namespaces, views, WebMCP tools
|
|
21
|
+
▼
|
|
22
|
+
https://your.site/tools-page ← a browser, or fangorn-mcp driving Chrome
|
|
23
|
+
document.modelContext tools, static shards, no server
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
## What you need
|
|
27
|
+
|
|
28
|
+
- Node 22, and the SDK/CLI: `npm i -g @fangorn-network/sdk@2026.9.22-dev` (gives `fangorn`)
|
|
29
|
+
- A wallet on Arbitrum Sepolia with a little ETH. **It owns the app forever**; use the
|
|
30
|
+
wallet that will publish the data.
|
|
31
|
+
- A Pinata JWT. ERC-8004 registration pins a registration file to IPFS.
|
|
32
|
+
- A static host that serves `/.well-known/` and custom headers (Cloudflare Pages does both).
|
|
33
|
+
- `@fangorn/westmarch` for the card generator and the discovery helpers.
|
|
34
|
+
|
|
35
|
+
Configure the CLI once:
|
|
36
|
+
|
|
37
|
+
```sh
|
|
38
|
+
fangorn init # writes ~/.fangorn/config.json: key, Pinata JWT, gateway
|
|
39
|
+
fangorn wallet # confirm which address will sign
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
> The config file wins over `ETH_PRIVATE_KEY` in the environment. If `fangorn wallet`
|
|
43
|
+
> shows the wrong address, that's why.
|
|
44
|
+
|
|
45
|
+
## 1. Expose tools with WebMCP
|
|
46
|
+
|
|
47
|
+
The site's agent surface is a function that registers tools on `document.modelContext`.
|
|
48
|
+
Guard it, since most browsers don't have WebMCP yet:
|
|
49
|
+
|
|
50
|
+
```js
|
|
51
|
+
// app/agent.js
|
|
52
|
+
import { search, getRow } from "@fangorn/westmarch/tools";
|
|
53
|
+
|
|
54
|
+
const ok = (o) => ({ content: [{ type: "text", text: JSON.stringify(o) }] });
|
|
55
|
+
|
|
56
|
+
export function registerAgent(ctx) {
|
|
57
|
+
const mc = document.modelContext;
|
|
58
|
+
if (!mc?.registerTool) return 0;
|
|
59
|
+
mc.registerTool({
|
|
60
|
+
name: "search-rows",
|
|
61
|
+
description: "Search the corpus by meaning. Call this first.",
|
|
62
|
+
inputSchema: { type: "object", properties: { query: { type: "string" } } },
|
|
63
|
+
execute: async ({ query }) => ok(search(ctx.rows(), query, ctx.roles(), { qv: await ctx.queryVector(query) })),
|
|
64
|
+
});
|
|
65
|
+
// …more tools
|
|
66
|
+
}
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
Call it only once the data the tools read has loaded, not at module top level. A human
|
|
70
|
+
clicks after the page has settled; an agent calls the moment a tool appears, and a tool
|
|
71
|
+
that answers before its data exists answers wrong without erroring. (Kingsfoil's first
|
|
72
|
+
search routed to zero areas until its tools waited for `routes.json`.)
|
|
73
|
+
|
|
74
|
+
```js
|
|
75
|
+
const data = await loadIndex(); // whatever the tools read
|
|
76
|
+
registerAgent(ctxOver(data));
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
`@fangorn/westmarch/tools` holds verbs that work on any corpus (describe, search, browse,
|
|
80
|
+
facet, get, neighbours). Add a domain verb only when the generic ones can't express it.
|
|
81
|
+
|
|
82
|
+
## 2. Generate the agent card
|
|
83
|
+
|
|
84
|
+
A WebMCP tool only exists inside a loaded tab, so the card is the only machine-readable
|
|
85
|
+
record that the tools exist. Don't write the tool list by hand. `captureTools` runs your
|
|
86
|
+
register function against a recording stub, so the card lists exactly what the browser
|
|
87
|
+
registers. No `execute` runs during capture.
|
|
88
|
+
|
|
89
|
+
```js
|
|
90
|
+
// pipeline/agent-card.mjs — run at build time, after the site is built
|
|
91
|
+
import { writeFileSync, mkdirSync } from "node:fs";
|
|
92
|
+
import { agentCard, captureTools } from "@fangorn/westmarch/agent-card";
|
|
93
|
+
import { registerAgent } from "../app/agent.js";
|
|
94
|
+
|
|
95
|
+
const tools = await captureTools(() => registerAgent({}));
|
|
96
|
+
const card = agentCard({
|
|
97
|
+
name: "My App",
|
|
98
|
+
description: "What a stranger gets from this, in two sentences.",
|
|
99
|
+
url: "https://my-app.pages.dev/tools.html", // the page that REGISTERS the tools, not just the site
|
|
100
|
+
version: "2026-09-22",
|
|
101
|
+
tools,
|
|
102
|
+
tags: ["my-app"],
|
|
103
|
+
fangorn: {
|
|
104
|
+
app: "my-app", // the name you will claim in step 4
|
|
105
|
+
fromBlock: 311671082, // see below
|
|
106
|
+
namespaces: ["my-app"], // where your data is published
|
|
107
|
+
views: ["https://my-app.pages.dev/v1"], // baked quickbeam views, if any
|
|
108
|
+
},
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
mkdirSync("site/.well-known", { recursive: true });
|
|
112
|
+
writeFileSync("site/.well-known/agent-card.json", JSON.stringify(card, null, 1));
|
|
113
|
+
// Discovery runs in other sites' tabs: without CORS the browser won't hand over the card.
|
|
114
|
+
writeFileSync("site/_headers", "/.well-known/agent-card.json\n Access-Control-Allow-Origin: *\n");
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
The `fangorn` block becomes an A2A extension (`https://fangorn.network/a2a/app/v1`) holding
|
|
118
|
+
the chain, the registry addresses (taken from the SDK, so they can't drift), your appId,
|
|
119
|
+
`fromBlock`, `namespaces` and `views`. `agentCard` validates these fields the same way
|
|
120
|
+
readers will, so a bad card fails your build, not someone else's discovery.
|
|
121
|
+
|
|
122
|
+
**`fromBlock`** is where readers start scanning for your data. Set it to the current block
|
|
123
|
+
**before** you claim the app:
|
|
124
|
+
|
|
125
|
+
```sh
|
|
126
|
+
node -e 'const {createPublicClient,http}=require("viem");const {arbitrumSepolia}=require("viem/chains");
|
|
127
|
+
createPublicClient({chain:arbitrumSepolia,transport:http()}).getBlockNumber().then(String).then(console.log)'
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
Too early is only slow: readers query logs 1000 blocks per call. Leave it at 0 and a reader
|
|
131
|
+
makes hundreds of thousands of calls.
|
|
132
|
+
|
|
133
|
+
**Hard-code these values once the app is claimed.** A rebuild that drops the extension
|
|
134
|
+
publishes a card that every reader rejects.
|
|
135
|
+
|
|
136
|
+
## 3. Deploy and check the card
|
|
137
|
+
|
|
138
|
+
```sh
|
|
139
|
+
npx wrangler pages deploy site --project-name my-app
|
|
140
|
+
curl -sD - https://my-app.pages.dev/.well-known/agent-card.json -o /dev/null | grep -iE "^HTTP|content-type|access-control"
|
|
141
|
+
# HTTP/2 200 · content-type: application/json · access-control-allow-origin: *
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
The card has to be live before step 5, because the CLI fetches it.
|
|
145
|
+
|
|
146
|
+
## 4. Claim the app
|
|
147
|
+
|
|
148
|
+
```sh
|
|
149
|
+
fangorn --app my-app app info # "unclaimed", and the wallet you expect
|
|
150
|
+
fangorn --app my-app app claim # first come, first served; permanent
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
With no flags, `claim` uses placeholder terms. Publish real ones later with
|
|
154
|
+
`fangorn app terms <hash> <uri>`; anyone who joined under the old terms has to accept again.
|
|
155
|
+
`--fee <wei>` sets a join fee.
|
|
156
|
+
|
|
157
|
+
## 5. Register the agent and bind the card
|
|
158
|
+
|
|
159
|
+
```sh
|
|
160
|
+
fangorn --app my-app app agent https://my-app.pages.dev/.well-known/agent-card.json
|
|
161
|
+
# Agent ID: 421614:226
|
|
162
|
+
# Card: https://my-app.pages.dev/.well-known/agent-card.json
|
|
163
|
+
# Tx: 0x…
|
|
164
|
+
```
|
|
165
|
+
|
|
166
|
+
This command does two things:
|
|
167
|
+
|
|
168
|
+
1. **ERC-8004.** It builds a registration file from the card (name, description, A2A
|
|
169
|
+
endpoint = the card URL, trust = reputation, x402 only if a skill is tagged `x402`),
|
|
170
|
+
pins it to Pinata, and mints an agent in the identity registry at
|
|
171
|
+
`0x8004A818BFB912233c491871b3d84c89A494BD9e`. That's the same address as on Ethereum
|
|
172
|
+
and Base Sepolia. [8004scan](https://8004scan.io) indexes it within minutes.
|
|
173
|
+
2. **Fangorn.** It calls `setAppAgentUri(cardUrl)`, which emits `AppAgentChanged`. That log
|
|
174
|
+
is the app directory: westmarch's `listApps` reads it.
|
|
175
|
+
|
|
176
|
+
`--skip-register` does only the second step, e.g. to move a card that's already registered
|
|
177
|
+
to a new URL.
|
|
178
|
+
|
|
179
|
+
## 6. Publish data into the app
|
|
180
|
+
|
|
181
|
+
The owner is the app's first publisher, but still needs global standing:
|
|
182
|
+
|
|
183
|
+
```sh
|
|
184
|
+
fangorn --app my-app register
|
|
185
|
+
fangorn --app my-app repo init my-app
|
|
186
|
+
fangorn --app my-app commit graph.json -m "first bake" --replace
|
|
187
|
+
fangorn --app my-app push
|
|
188
|
+
```
|
|
189
|
+
|
|
190
|
+
`graph.json` is `{ vertices: [{id, tag, payload}], edges?: [{rel, from, to}] }`.
|
|
191
|
+
`westmarch/publish/graph.js` builds one from a file tree.
|
|
192
|
+
|
|
193
|
+
## 7. Discover it
|
|
194
|
+
|
|
195
|
+
Every route below ends in `discoverApp(cardUrl)`, which accepts a card only if:
|
|
196
|
+
|
|
197
|
+
- the card carries the Fangorn extension for **this** chain and **this** AppRegistry, and
|
|
198
|
+
- `appAgentUri(card.appId)` on chain is **exactly** the card URL.
|
|
199
|
+
|
|
200
|
+
The registry and the index are where you look. The on-chain binding is what you trust.
|
|
201
|
+
|
|
202
|
+
`example/discover.mjs` runs all three routes:
|
|
203
|
+
|
|
204
|
+
```sh
|
|
205
|
+
node example/discover.mjs card https://kingsfoil.pages.dev/.well-known/agent-card.json
|
|
206
|
+
node example/discover.mjs chain 311637349 # every app bound since this block
|
|
207
|
+
node example/discover.mjs search Kingsfoil # the public ERC-8004 index
|
|
208
|
+
```
|
|
209
|
+
|
|
210
|
+
```
|
|
211
|
+
Kingsfoil https://kingsfoil.pages.dev/.well-known/agent-card.json
|
|
212
|
+
app 0x3069…c8ca data from block 311671082 namespaces kingsfoil
|
|
213
|
+
open https://kingsfoil.pages.dev — WebMCP tools: list-areas, search-trials, get-trial, …
|
|
214
|
+
```
|
|
215
|
+
|
|
216
|
+
**From a card URL** (someone handed it to you):
|
|
217
|
+
|
|
218
|
+
```js
|
|
219
|
+
import { Fangorn, FangornConfig } from "@fangorn-network/sdk";
|
|
220
|
+
import { generatePrivateKey } from "viem/accounts";
|
|
221
|
+
// Fangorn.create needs a signer; a throwaway key is fine for reading.
|
|
222
|
+
const fangorn = Fangorn.create({ privateKey: generatePrivateKey(), config: FangornConfig });
|
|
223
|
+
const { card, appId, fromBlock, namespaces } = await fangorn.discoverApp(cardUrl);
|
|
224
|
+
```
|
|
225
|
+
|
|
226
|
+
**From the chain** (every Fangorn app; no index, no server):
|
|
227
|
+
|
|
228
|
+
```js
|
|
229
|
+
import { listApps } from "@fangorn/westmarch/apps";
|
|
230
|
+
const { apps, rejected } = await listApps(fangorn, { fromBlock: 311637349n });
|
|
231
|
+
// apps: [{ name, appId, card, url, views, namespaces, fromBlock }]
|
|
232
|
+
// rejected: [{ card, why }]: unreachable or not bound, reported rather than dropped
|
|
233
|
+
```
|
|
234
|
+
|
|
235
|
+
`sourcesFromChain` in `@fangorn/westmarch/directory` turns that into `findCorpora` sources,
|
|
236
|
+
so an agent can rank every app's corpora against a question without downloading a shard.
|
|
237
|
+
In the example page, `?fromBlock=311637349` does the same thing in a browser.
|
|
238
|
+
|
|
239
|
+
**From ERC-8004** (general-purpose agents that know nothing about Fangorn):
|
|
240
|
+
|
|
241
|
+
```js
|
|
242
|
+
const api = "https://8004scan.io/api/v1/agents";
|
|
243
|
+
const { items } = await (await fetch(`${api}?chain_id=421614&search=Kingsfoil`)).json();
|
|
244
|
+
const detail = await (await fetch(`${api}/421614/${items[0].token_id}`)).json();
|
|
245
|
+
const found = await fangorn.discoverApp(detail.services.a2a.endpoint);
|
|
246
|
+
```
|
|
247
|
+
|
|
248
|
+
Without the index, read it straight from the registry: `tokenURI(agentId)` returns the
|
|
249
|
+
registration file's `ipfs://` URI, and its `A2A` endpoint is the card.
|
|
250
|
+
|
|
251
|
+
### Use it
|
|
252
|
+
|
|
253
|
+
- **An agent:** open `card.url` in Chrome 150+ with `--enable-features=WebMCP` (headless
|
|
254
|
+
works) and call the tools the card lists as `webmcp` skills. Queries run in that tab, and
|
|
255
|
+
nothing reaches a server. WebMCP is not a network MCP server: the tools exist only inside
|
|
256
|
+
a loaded tab, so an agent outside the browser (Claude Code, a script) needs something
|
|
257
|
+
that drives the tab for it. `card.url` must be the page that registers the tools, not
|
|
258
|
+
just the site's root. `fangorn-mcp` is that driver, for every app at once. Register it
|
|
259
|
+
once:
|
|
260
|
+
|
|
261
|
+
```sh
|
|
262
|
+
claude mcp add fangorn -- npx -y -p @fangorn/westmarch fangorn-mcp
|
|
263
|
+
```
|
|
264
|
+
|
|
265
|
+
or, for any MCP client:
|
|
266
|
+
|
|
267
|
+
```json
|
|
268
|
+
{ "mcpServers": { "fangorn": {
|
|
269
|
+
"command": "npx", "args": ["-y", "-p", "@fangorn/westmarch", "fangorn-mcp"],
|
|
270
|
+
"env": { "FANGORN_MCP_CDP": "ws://your-browser:9222" } } } }
|
|
271
|
+
```
|
|
272
|
+
|
|
273
|
+
Drop `env` to use the local Chrome.
|
|
274
|
+
|
|
275
|
+
It starts with three tools. `list-apps` reads the apps off the chain and keeps the ones
|
|
276
|
+
whose cards verify. `open-app kingsfoil` verifies that app's card, opens `card.url` in a
|
|
277
|
+
tab of one shared Chrome with WebMCP on, and adds the page's tools as
|
|
278
|
+
`kingsfoil__search-trials` and so on. `call-app-tool` reaches the same tools for clients
|
|
279
|
+
that don't refresh their tool list. A new app needs no new registration: it shows up in
|
|
280
|
+
`list-apps` once its card is bound on chain.
|
|
281
|
+
|
|
282
|
+
**Where the browser comes from.** Anything that speaks the Chrome DevTools Protocol and
|
|
283
|
+
has WebMCP will do: a headless Chromium service, a hosted browser, or the local Chrome.
|
|
284
|
+
|
|
285
|
+
| setting | browser |
|
|
286
|
+
|---|---|
|
|
287
|
+
| `FANGORN_MCP_CDP=http://host:9222` (or `--cdp`) | a running browser's DevTools address |
|
|
288
|
+
| `FANGORN_MCP_CDP=ws://host:9222/devtools/browser/…` | a browser WebSocket endpoint, which is what hosted services hand out |
|
|
289
|
+
| neither | the local Chrome 150+ (`CHROME=/path` if it isn't `google-chrome`), headless (`--headed` to watch) |
|
|
290
|
+
|
|
291
|
+
A remote browser is never closed. Only the tabs the server opened are. A local Chrome is
|
|
292
|
+
shared by every session on the machine and closes when the last one exits. Its profile
|
|
293
|
+
(`~/.cache/westmarch-mcp/`) keeps page downloads such as an embedding model.
|
|
294
|
+
`--from-block <n>` points the server at another deployment.
|
|
295
|
+
|
|
296
|
+
- **Without a browser:** the views are plain HTTP (`<view>/cdn/catalog`, then the shards it
|
|
297
|
+
names). Point `westmarch/consume/shard.js` at `views[i]`.
|
|
298
|
+
- **The committed data itself:**
|
|
299
|
+
|
|
300
|
+
```js
|
|
301
|
+
fangorn.setAppId(appId);
|
|
302
|
+
const timelines = await fangorn.appNamespaces({ namespace: namespaces[0], fromBlock });
|
|
303
|
+
const { contents } = await fangorn.readNamespace(timelines[0].owner, namespaces[0]);
|
|
304
|
+
```
|
|
305
|
+
|
|
306
|
+
## What the binding proves, and what it doesn't
|
|
307
|
+
|
|
308
|
+
- **Proves:** whoever owns the app on chain chose this card URL. Anyone can serve a card
|
|
309
|
+
claiming any appId, and `discoverApp` rejects it unless the app points back at that exact
|
|
310
|
+
URL. This is tested (`src/discover-app.test.ts`, `src/app-agent.e2e.test.ts`).
|
|
311
|
+
- **Doesn't prove:** that the page behind `card.url` behaves as described, or that the card
|
|
312
|
+
wasn't changed after binding. The card is live HTTP, not content-addressed, and the
|
|
313
|
+
binding is to the URL. That's deliberate: the card is mostly endpoints, which change, and
|
|
314
|
+
pinning each version would cost a transaction for nothing. The data's integrity comes
|
|
315
|
+
from the commits, not from the card.
|
|
316
|
+
|
|
317
|
+
## Gotchas
|
|
318
|
+
|
|
319
|
+
| symptom | cause |
|
|
320
|
+
|---|---|
|
|
321
|
+
| `not bound to app … on-chain agent_uri is …` | URLs must match exactly. A trailing slash, `http` vs `https`, or a different path counts as a different card. |
|
|
322
|
+
| card rejected after a rebuild | the build dropped the `fangorn` block or changed `fromBlock`/`url`; hard-code them |
|
|
323
|
+
| discovery works in Node, fails in a browser | no `Access-Control-Allow-Origin` on the card |
|
|
324
|
+
| `listApps` takes forever | `fromBlock` is too early; the scan is 1000 blocks per RPC call |
|
|
325
|
+
| a log scan misses a transaction you just sent | fixed in the SDK (uncached head block); upgrade to ≥ 2026.9.22-dev |
|
|
326
|
+
| IPFS reads return "switching to a service worker gateway" | ipfs.io stopped serving raw content; use a Pinata gateway |
|
|
327
|
+
| `app agent` signs as the wrong wallet | `~/.fangorn/config.json` beats `ETH_PRIVATE_KEY` |
|
|
328
|
+
| an agent opens the page and finds no tools | `card.url` is the site root, but the tools register on another page |
|
|
329
|
+
| an agent's first call returns empty, later calls work | the tools registered before their data loaded (step 1) |
|
|
330
|
+
| `fangorn-mcp`: "this browser has no WebMCP" | the browser at `FANGORN_MCP_CDP` (or the local Chrome) lacks `document.modelContext`; it needs Chrome 150+ with `--enable-features=WebMCP`, or a build with it on |
|
|
331
|
+
| `fangorn-mcp`: "no browser: cannot launch google-chrome" | no local Chrome; set `FANGORN_MCP_CDP` |
|
|
332
|
+
| the page isn't listed as a "Web" service on 8004scan | agent0-sdk has no web endpoint type; agents find the page through the card's `url` |
|
|
333
|
+
|
|
334
|
+
## Reference: Kingsfoil
|
|
335
|
+
|
|
336
|
+
| | |
|
|
337
|
+
|---|---|
|
|
338
|
+
| site | https://kingsfoil.pages.dev |
|
|
339
|
+
| tools page (`card.url`) | https://kingsfoil.pages.dev/provider.html (the root is the patient view, with no tools) |
|
|
340
|
+
| card | https://kingsfoil.pages.dev/.well-known/agent-card.json |
|
|
341
|
+
| app | `kingsfoil` = `0x3069aaab9c73b01fb147a97106c7e595534850af5cab0e0ff7f2a55554d5c8ca` |
|
|
342
|
+
| owner | `0x7a7849231cF7Ab1EA003BcF0063CB89704D7Cce9` |
|
|
343
|
+
| fromBlock | 311671082 |
|
|
344
|
+
| claim tx | `0x881a88e4775c6360f799503b098f00ef470806278f7b282045d6cc881df3cf72` |
|
|
345
|
+
| ERC-8004 | agent `421614:226`, registration `ipfs://bafkreigmy2ubgzrsp55fnf77fykabapuv4y42nk6peq4ko3oi3w2x7u7ay` |
|
|
346
|
+
| bind tx | `0xc7d8726d31bb9698ba2bfae3bc107a6111062d4b7e26526301a984638ab94c9f` |
|
|
347
|
+
| card script | `kingsfoil/pipeline/agent-card.mjs` |
|
|
348
|
+
|
|
349
|
+
Deployment (Arbitrum Sepolia, 421614): AppRegistry `0x11d228c4774af3d9cae3b4b6874a12576a1a83ec`,
|
|
350
|
+
DataRegistry `0x775026e905d7b58b34d16bcbd385fa630ee36c26`, earliest AppRegistry event at block
|
|
351
|
+
311637349.
|
package/package.json
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@fangorn-network/westmarch",
|
|
3
|
+
"version": "0.0.1-dev",
|
|
4
|
+
"description": "Publisher and consumer surfaces for a Fangorn app: shape a graph, publish it, read it back as a searchable, agent-drivable catalog",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"exports": {
|
|
8
|
+
"./graph": "./publish/graph.js",
|
|
9
|
+
"./envelope": "./publish/envelope.js",
|
|
10
|
+
"./enrich": "./publish/enrich.js",
|
|
11
|
+
"./shard": "./consume/shard.js",
|
|
12
|
+
"./embed": "./consume/embed.js",
|
|
13
|
+
"./rank": "./consume/rank.js",
|
|
14
|
+
"./apps": "./consume/apps.js",
|
|
15
|
+
"./agent-card": "./publish/agent-card.js",
|
|
16
|
+
"./roles": "./consume/roles.js",
|
|
17
|
+
"./directory": "./consume/directory.js",
|
|
18
|
+
"./taste": "./consume/taste.js",
|
|
19
|
+
"./corpora": "./consume/corpora.js",
|
|
20
|
+
"./ui": "./consume/ui.js",
|
|
21
|
+
"./lint": "./consume/lint.js",
|
|
22
|
+
"./terms": "./publish/terms.js",
|
|
23
|
+
"./reactions": "./publish/reactions.js",
|
|
24
|
+
"./cohort": "./publish/cohort.js",
|
|
25
|
+
"./vault": "./publish/vault.js",
|
|
26
|
+
"./taste-doc": "./consume/taste-doc.js",
|
|
27
|
+
"./settle": "./publish/settle.js",
|
|
28
|
+
"./manifest": "./publish/manifest.js",
|
|
29
|
+
"./demand": "./publish/demand.js",
|
|
30
|
+
"./steam": "./consume/steam.js",
|
|
31
|
+
"./tools": "./consume/tools.js",
|
|
32
|
+
"./package.json": "./package.json"
|
|
33
|
+
},
|
|
34
|
+
"scripts": {
|
|
35
|
+
"test": "node publish/terms.js && node publish/reactions.js && node publish/cohort.js && node publish/vault.js && node publish/graph.js && node publish/envelope.js && node publish/demand.js && node publish/settle.js && node publish/manifest.js && node publish/agent-card.js && node publish/cli.js --selfcheck && node publish/enrich.js && node consume/embed.js && node consume/rank.js && node consume/apps.js && node consume/mcp.js --selfcheck && node consume/roles.js && node consume/shard.js && node consume/directory.js && node consume/taste.js && node consume/steam.js && node consume/taste-doc.js && node consume/corpora.js && node consume/ui.js && node consume/lint.js && node consume/tools.js",
|
|
36
|
+
"lint": "node consume/lint.js"
|
|
37
|
+
},
|
|
38
|
+
"peerDependencies": {
|
|
39
|
+
"@huggingface/transformers": "^4.2.0",
|
|
40
|
+
"viem": "^2.55.1",
|
|
41
|
+
"@fangorn-network/sdk": "^2026.9.22-dev",
|
|
42
|
+
"@noble/curves": "^1.9.1"
|
|
43
|
+
},
|
|
44
|
+
"peerDependenciesMeta": {
|
|
45
|
+
"@huggingface/transformers": {
|
|
46
|
+
"optional": true
|
|
47
|
+
}
|
|
48
|
+
},
|
|
49
|
+
"devDependencies": {
|
|
50
|
+
"@huggingface/transformers": "^4.2.0",
|
|
51
|
+
"viem": "^2.56.3",
|
|
52
|
+
"@fangorn-network/sdk": "^2026.9.22-dev",
|
|
53
|
+
"@noble/curves": "^1.9.1"
|
|
54
|
+
},
|
|
55
|
+
"bin": {
|
|
56
|
+
"westmarch-publish": "./publish/cli.js",
|
|
57
|
+
"fangorn-mcp": "./consume/mcp.js"
|
|
58
|
+
},
|
|
59
|
+
"files": [
|
|
60
|
+
"consume",
|
|
61
|
+
"publish",
|
|
62
|
+
"docs/app-to-agent.md"
|
|
63
|
+
],
|
|
64
|
+
"engines": {
|
|
65
|
+
"node": ">=20"
|
|
66
|
+
},
|
|
67
|
+
"publishConfig": {
|
|
68
|
+
"access": "public"
|
|
69
|
+
}
|
|
70
|
+
}
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
// The A2A agent card for a runtime-free backend: `/.well-known/agent-card.json`.
|
|
2
|
+
//
|
|
3
|
+
// A WebMCP tool lives on `document.modelContext` inside a loaded tab. There is
|
|
4
|
+
// no endpoint to crawl, so the card is the only machine-readable trace of the
|
|
5
|
+
// tools: a discovering agent learns the verbs exist from here or not at all.
|
|
6
|
+
//
|
|
7
|
+
// The tools are CAPTURED, not transcribed. The page's own register function is
|
|
8
|
+
// run against a stub `document.modelContext` that records what it registers.
|
|
9
|
+
// Registration only builds descriptors, and no `execute` fires, so the card is
|
|
10
|
+
// generated from the same declarations the browser sees. Add a tool and it
|
|
11
|
+
// appears on the next build; there is no second list to drift.
|
|
12
|
+
//
|
|
13
|
+
// The Fangorn extension is what makes the card an APP rather than a web page.
|
|
14
|
+
// It names the app, where its data starts on chain, and which namespaces and
|
|
15
|
+
// views hold it. It only counts once the app owner binds the card's URL on
|
|
16
|
+
// chain (`setAppAgentUri`). `discoverApp` refuses a card whose app does not
|
|
17
|
+
// point back at it, so writing someone else's appId here gets you nothing.
|
|
18
|
+
|
|
19
|
+
import { FANGORN_APP_EXTENSION, FangornConfig, appId as hashAppId } from "@fangorn-network/sdk";
|
|
20
|
+
|
|
21
|
+
/** Run `register` against a recording `document.modelContext`; return what it
|
|
22
|
+
* registered. `register` is whatever the page calls at load. */
|
|
23
|
+
export async function captureTools(register) {
|
|
24
|
+
const tools = [];
|
|
25
|
+
const had = "document" in globalThis, prev = globalThis.document;
|
|
26
|
+
globalThis.document = { modelContext: { registerTool: (t) => tools.push(t) } };
|
|
27
|
+
try { await register(); } finally {
|
|
28
|
+
if (had) globalThis.document = prev; else delete globalThis.document;
|
|
29
|
+
}
|
|
30
|
+
if (!tools.length) throw new Error("no tools captured — does register() call document.modelContext.registerTool?");
|
|
31
|
+
return tools;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const HEX32 = /^0x[0-9a-fA-F]{64}$/;
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* The card.
|
|
38
|
+
*
|
|
39
|
+
* `fangorn` is optional: without it this is a plain A2A card for a page with
|
|
40
|
+
* tools. With it, `{app, fromBlock, namespaces, views, config}`:
|
|
41
|
+
* app the app's name or its bytes32 id
|
|
42
|
+
* fromBlock a block at or before the app's registration. The reader's log
|
|
43
|
+
* scan starts here; leave it at 0 and a reader makes one RPC call
|
|
44
|
+
* per 1000 blocks of chain history.
|
|
45
|
+
* views view bases (quickbeam) holding the baked data. Read by
|
|
46
|
+
* westmarch's directory; the SDK ignores it.
|
|
47
|
+
* config the deployment; defaults to the SDK's, so the addresses in the
|
|
48
|
+
* card cannot drift from the ones `discoverApp` checks.
|
|
49
|
+
*/
|
|
50
|
+
export function agentCard({ name, description, url, version = "0.0.0", tools = [], tags = [], skills = [], fangorn, ...rest }) {
|
|
51
|
+
if (!name || !url) throw new Error("agentCard: name and url are required");
|
|
52
|
+
const card = {
|
|
53
|
+
protocolVersion: "0.3.0",
|
|
54
|
+
name, description: description ?? "", url, version,
|
|
55
|
+
capabilities: { streaming: false, pushNotifications: false, stateTransitionHistory: false },
|
|
56
|
+
defaultInputModes: ["application/json"],
|
|
57
|
+
defaultOutputModes: ["application/json"],
|
|
58
|
+
...rest,
|
|
59
|
+
skills: [
|
|
60
|
+
// `webmcp` is how a caller knows it needs a browser it can drive.
|
|
61
|
+
...tools.map((t) => ({
|
|
62
|
+
id: t.name,
|
|
63
|
+
name: t.name.replace(/-/g, " ").replace(/^./, (c) => c.toUpperCase()),
|
|
64
|
+
description: t.description ?? "",
|
|
65
|
+
tags: [...tags, "webmcp"],
|
|
66
|
+
})),
|
|
67
|
+
...skills,
|
|
68
|
+
],
|
|
69
|
+
};
|
|
70
|
+
if (fangorn) card.capabilities.extensions = [fangornExtension(fangorn)];
|
|
71
|
+
return card;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** The params `discoverApp` reads, validated the same way it validates them.
|
|
75
|
+
* A malformed card should fail the build, not the first reader. */
|
|
76
|
+
export function fangornExtension({ app, fromBlock, namespaces = [], views = [], config = FangornConfig }) {
|
|
77
|
+
if (!app) throw new Error("fangorn.app is required");
|
|
78
|
+
const id = HEX32.test(app) ? app : hashAppId(app);
|
|
79
|
+
const from = String(fromBlock ?? "");
|
|
80
|
+
if (!/^\d+$/.test(from)) throw new Error(`fangorn.fromBlock must be a non-negative integer, got ${fromBlock}`);
|
|
81
|
+
if (!namespaces.every((n) => typeof n === "string")) throw new Error("fangorn.namespaces must be strings");
|
|
82
|
+
for (const v of views) {
|
|
83
|
+
const u = new URL(v); // throws on garbage
|
|
84
|
+
if (u.protocol !== "https:" && u.protocol !== "http:") throw new Error(`fangorn.views: not http(s): ${v}`);
|
|
85
|
+
}
|
|
86
|
+
return {
|
|
87
|
+
uri: FANGORN_APP_EXTENSION,
|
|
88
|
+
description: "The Fangorn app this agent fronts.",
|
|
89
|
+
required: false,
|
|
90
|
+
params: {
|
|
91
|
+
chainId: config.caip2,
|
|
92
|
+
appRegistry: config.appRegistryContractAddress,
|
|
93
|
+
dataRegistry: config.dataRegistryContractAddress,
|
|
94
|
+
appId: id, fromBlock: from, namespaces, views,
|
|
95
|
+
},
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// ── self-check: `node publish/agent-card.js` ────────────────────────────────
|
|
100
|
+
if (typeof process !== "undefined" && import.meta.url === `file://${process.argv[1]}`) {
|
|
101
|
+
const eq = (a, b, m) => { if (JSON.stringify(a) !== JSON.stringify(b)) throw new Error(`${m}: ${JSON.stringify(a)} != ${JSON.stringify(b)}`); };
|
|
102
|
+
const throws = (f, re, m) => { try { f(); } catch (e) { if (re.test(e.message)) return; throw e; } throw new Error(`must throw: ${m}`); };
|
|
103
|
+
|
|
104
|
+
const { APP_EXTENSION } = await import("../consume/apps.js");
|
|
105
|
+
eq(APP_EXTENSION, FANGORN_APP_EXTENSION, "consume/apps.js's copy of the extension uri");
|
|
106
|
+
|
|
107
|
+
let fired = 0;
|
|
108
|
+
const tools = await captureTools(() => {
|
|
109
|
+
document.modelContext.registerTool({ name: "search-rows", description: "Search.", execute: () => fired++ });
|
|
110
|
+
document.modelContext.registerTool({ name: "get-row", description: "One row.", execute: () => fired++ });
|
|
111
|
+
});
|
|
112
|
+
eq(tools.map((t) => t.name), ["search-rows", "get-row"], "captured tools");
|
|
113
|
+
if (fired) throw new Error("capture must not execute tools");
|
|
114
|
+
if ("document" in globalThis) throw new Error("stub document must not leak");
|
|
115
|
+
await captureTools(() => {}).then(() => { throw new Error("empty capture must throw"); }, () => {});
|
|
116
|
+
|
|
117
|
+
const plain = agentCard({ name: "X", url: "https://x.test", tools, tags: ["x"] });
|
|
118
|
+
eq(plain.skills.map((s) => [s.id, s.name, s.tags]), [["search-rows", "Search rows", ["x", "webmcp"]], ["get-row", "Get row", ["x", "webmcp"]]], "skills");
|
|
119
|
+
if (plain.capabilities.extensions) throw new Error("no fangorn → no extension");
|
|
120
|
+
|
|
121
|
+
const card = agentCard({ name: "X", url: "https://x.test", tools, fangorn: { app: "kingsfoil", fromBlock: 5n, namespaces: ["kingsfoil"], views: ["https://x.test/v1"] } });
|
|
122
|
+
const [ext] = card.capabilities.extensions;
|
|
123
|
+
eq(ext.uri, FANGORN_APP_EXTENSION, "extension uri");
|
|
124
|
+
eq(ext.params.appId, hashAppId("kingsfoil"), "app name hashes to the id the registry uses");
|
|
125
|
+
eq([ext.params.chainId, ext.params.appRegistry, ext.params.fromBlock], [FangornConfig.caip2, FangornConfig.appRegistryContractAddress, "5"], "deployment + fromBlock as a decimal string");
|
|
126
|
+
eq(fangornExtension({ app: ext.params.appId, fromBlock: 0 }).params.appId, ext.params.appId, "a bytes32 id passes through");
|
|
127
|
+
|
|
128
|
+
throws(() => fangornExtension({ app: "a", fromBlock: -1 }), /fromBlock/, "negative fromBlock");
|
|
129
|
+
throws(() => fangornExtension({ app: "a" }), /fromBlock/, "missing fromBlock");
|
|
130
|
+
throws(() => fangornExtension({ app: "a", fromBlock: 0, views: ["javascript:alert(1)"] }), /views/, "non-http view");
|
|
131
|
+
throws(() => agentCard({ name: "X" }), /url/, "missing url");
|
|
132
|
+
|
|
133
|
+
console.log("agent-card.js self-check ok — tools captured without executing, extension matches the SDK's, bad params fail the build");
|
|
134
|
+
}
|