@anchrd/intel-api 0.10.0 → 0.12.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/README.md CHANGED
@@ -98,6 +98,34 @@ routes answer `503 agent_runtime_not_configured` and nothing else changes. Deplo
98
98
  before an Intel deployment that declares it — Wrangler refuses a binding to a service that does not
99
99
  exist yet.
100
100
 
101
+ ### What an agent costs, and what a model costs
102
+
103
+ Two figures on the agent screen come from Cloudflare rather than from a table in this repository:
104
+ what one agent has actually spent, per run and over the last 7 and 30 days, and what each offered
105
+ model charges per million tokens. Both are read by Intel and never by the browser, because reading
106
+ them needs an account credential and a credential does not belong in a single-page app.
107
+
108
+ Set `AI_GATEWAY_ACCOUNT_ID` and `AI_GATEWAY_ID` — the same account and gateway the agent runtime
109
+ uses — and add a Wrangler secret:
110
+
111
+ ```sh
112
+ wrangler secret put AI_GATEWAY_READ_TOKEN # Cloudflare API token: "AI Gateway: Read" + "Workers AI: Read"
113
+ ```
114
+
115
+ ⚠️ **This is not the runtime's `AI_GATEWAY_TOKEN`.** That one carries `AI Gateway: Run` and buys
116
+ inference; this one only reads. Cloudflare cannot scope `AI Gateway: Read` to a single gateway — it
117
+ is account-wide — which is worth knowing when the token is created and is not a reason against it.
118
+
119
+ All three are optional. Without them the cost view says *"this installation has no AI Gateway read
120
+ token"* and the model figures come off a built-in table that the interface marks as possibly out of
121
+ date. Neither is an error state: an installation that gives Intel no Cloudflare credential is a
122
+ supported installation. What is never done is show a zero — a missing figure and "cost nothing" are
123
+ different answers, and only one of them is about money.
124
+
125
+ The attribution comes from the agent runtime, which stamps `agentId` and `runId` into
126
+ `cf-aig-metadata` on every model call. Runs made before that stamp existed appear with their token
127
+ counts and without a cost, and say so.
128
+
101
129
  ### Creating an agent creates its Gate application
102
130
 
103
131
  An agent acts as its own machine principal, so creating an agent node also creates the Gate
@@ -1,15 +1,18 @@
1
1
  import { createGateClient } from "@anchrd/gate-sdk";
2
2
  import { ulid } from "ulid";
3
+ import { createAgentCosts } from "../../agent-costs/agent-costs.js";
3
4
  import { createAgentRuntimeService } from "../../agent-runtime/agent-runtime.js";
4
5
  import { createBrowserAuth } from "../../auth/auth.js";
5
6
  import { createBundle } from "../../bundle/bundle.js";
6
7
  import { createFlows } from "../../flows/flows.js";
7
8
  import { createIndexing, PermanentIndexingError } from "../../indexing/indexing.js";
8
9
  import { createIntel } from "../../intel/intel.js";
10
+ import { createModelCatalog } from "../../model-catalog/model-catalog.js";
9
11
  import { createNodes } from "../../nodes/nodes.js";
10
12
  import { IntelError } from "../../shared/intel-error/intel-error.js";
11
13
  import { sha256Hex } from "../../shared/sha256/sha256.js";
12
14
  import { createTools } from "../../tools/tools.js";
15
+ import { createCloudflareApi } from "../cloudflare-api/cloudflare-api.js";
13
16
  import { createContentStore } from "../content/content.js";
14
17
  import { createNodeRepository } from "../db/db.js";
15
18
  import { createFlowRepository } from "../db/db-flows.js";
@@ -133,6 +136,28 @@ export default {
133
136
  // same question as whether they may see the node, and no second answer is invented here.
134
137
  visibleNode: async (actor, nodeId) => await nodes.visibleNode(actor, nodeId),
135
138
  });
139
+ // ⚠️ One Cloudflare read client for both of the account's answers — the gateway's cost log
140
+ // (#251) and the Workers AI price list (#257) — because it is one token and one account. It is
141
+ // absent as a whole where any of the three variables is missing: a half-configured client would
142
+ // fail per call with a 401 that reads like a permission problem instead of saying, once, that
143
+ // this deployment was never given a token.
144
+ const gatewayAccountId = env.AI_GATEWAY_ACCOUNT_ID?.trim();
145
+ const gatewayId = env.AI_GATEWAY_ID?.trim();
146
+ const gatewayReadToken = env.AI_GATEWAY_READ_TOKEN?.trim();
147
+ const cloudflareApi = gatewayAccountId && gatewayId && gatewayReadToken
148
+ ? createCloudflareApi({
149
+ accountId: gatewayAccountId,
150
+ gatewayId,
151
+ token: gatewayReadToken,
152
+ fetch: globalThis.fetch.bind(globalThis),
153
+ })
154
+ : undefined;
155
+ const agentCosts = createAgentCosts({
156
+ ...(cloudflareApi ? { gateway: cloudflareApi } : {}),
157
+ visibleNode: async (actor, nodeId) => await nodes.visibleNode(actor, nodeId),
158
+ now,
159
+ });
160
+ const models = createModelCatalog({ ...(cloudflareApi ? { cloudflare: cloudflareApi } : {}) });
136
161
  const nodes = createNodes({
137
162
  repository: nodeRepository,
138
163
  content: contentStore,
@@ -223,6 +248,8 @@ export default {
223
248
  indexing: createIndexQueue(env.INDEXING),
224
249
  }),
225
250
  agents,
251
+ agentCosts,
252
+ models,
226
253
  auth,
227
254
  }).fetch(request);
228
255
  },
@@ -39,6 +39,25 @@ export interface CloudflareEnv {
39
39
  * it is absent, creating an agent refuses by name instead of leaving one without a key.
40
40
  */
41
41
  AGENT_HANDOVER_SECRET?: string;
42
+ /**
43
+ * Where the AI Gateway lives and how to READ it (#251, #257).
44
+ *
45
+ * ⚠️ `AI_GATEWAY_READ_TOKEN` is not the agent runtime's `AI_GATEWAY_TOKEN` and must never be the
46
+ * same value. That one carries `AI Gateway: Run` and buys inference; this one carries
47
+ * `AI Gateway: Read` — plus `Workers AI: Read` for the model catalog — and buys nothing at all.
48
+ * One credential doing both would put a spending permission into the Worker that only reads.
49
+ *
50
+ * ⚠️ Cloudflare cannot scope `AI Gateway: Read` to a single gateway; it is account-wide, the same
51
+ * limitation `AI Gateway: Run` already had in #239. It is worth knowing when the token is created
52
+ * and is not a reason against the route: it reads and writes nothing.
53
+ *
54
+ * ⚠️ All three optional, together. A deployment that configures none of them gets a cost view that
55
+ * says "not configured" and a model catalog off the built-in table — both named states, neither an
56
+ * error. Intel without a Cloudflare token is a supported installation.
57
+ */
58
+ AI_GATEWAY_ACCOUNT_ID?: string;
59
+ AI_GATEWAY_ID?: string;
60
+ AI_GATEWAY_READ_TOKEN?: string;
42
61
  TOOL_SOURCE_ORIGINS: string;
43
62
  MCP_PORTAL_URL?: string;
44
63
  ALLOW_INSECURE_OAUTH?: string;
@@ -0,0 +1,22 @@
1
+ import type { CloudflareAccountApi } from "./cloudflare-api.types.js";
2
+ /**
3
+ * ⚠️ `Authorization: Bearer`, and NOT `cf-aig-authorization`. The two hosts take different headers
4
+ * and the agent runtime uses the other one: `gateway.ai.cloudflare.com` reads
5
+ * `cf-aig-authorization`, this REST host reads the plain header. Swapping them produces a 401 that
6
+ * says nothing about which of the two was wrong.
7
+ */
8
+ export interface CloudflareApiDeps {
9
+ accountId: string;
10
+ gatewayId: string;
11
+ /**
12
+ * A Cloudflare API token, read-only by intent.
13
+ *
14
+ * ⚠️ It is NOT the `AI_GATEWAY_TOKEN` the agent runtime holds. That one carries
15
+ * `AI Gateway: Run` and buys inference; this one carries `AI Gateway: Read` (and, for the model
16
+ * catalog, `Workers AI: Read`) and buys nothing at all. One credential for both would put a
17
+ * spending permission into the Worker that only ever reads.
18
+ */
19
+ token: string;
20
+ fetch: typeof fetch;
21
+ }
22
+ export declare function createCloudflareApi(deps: CloudflareApiDeps): CloudflareAccountApi;
@@ -0,0 +1,189 @@
1
+ import { z } from "zod";
2
+ import { IntelError } from "../../shared/intel-error/intel-error.js";
3
+ const ApiOrigin = "https://api.cloudflare.com/client/v4";
4
+ /**
5
+ * How many pages of gateway log are read before the answer is declared a floor.
6
+ *
7
+ * A run is several model turns and a five-minute schedule is 8 640 runs a month, so "read
8
+ * everything" is not on the table. Ten pages of a hundred is the last thirty days of a busy agent
9
+ * or the last few days of a very busy one; past that the screen says "at least this much" rather
10
+ * than a number nobody can check.
11
+ */
12
+ const MaxPages = 10;
13
+ const PerPage = 100;
14
+ /**
15
+ * The gateway's log entry, read tolerantly.
16
+ *
17
+ * ⚠️ `metadata` arrives as an object on some responses and as a JSON string on others, and neither
18
+ * is documented as the one shape. Both are accepted; anything else means the call is unattributed,
19
+ * which is a state the caller can see rather than a parse failure that blanks the whole window.
20
+ */
21
+ const LogEntry = z.object({
22
+ cost: z.number().nullish(),
23
+ model: z.string().nullish(),
24
+ created_at: z.string().nullish(),
25
+ metadata: z.union([z.string(), z.record(z.string(), z.unknown())]).nullish(),
26
+ });
27
+ /**
28
+ * ⚠️ `result` is required, only its contents may be null. An optional field would let ANY JSON body
29
+ * parse as an empty page — and an empty page reads as "this agent cost nothing", which is the one
30
+ * answer this whole path exists to avoid giving by accident.
31
+ */
32
+ const LogResponse = z.object({
33
+ success: z.boolean().nullish(),
34
+ result: z.array(LogEntry).nullable(),
35
+ });
36
+ function readMetadata(raw) {
37
+ if (typeof raw === "string") {
38
+ try {
39
+ const parsed = JSON.parse(raw);
40
+ return parsed && typeof parsed === "object" && !Array.isArray(parsed)
41
+ ? parsed
42
+ : {};
43
+ }
44
+ catch {
45
+ return {};
46
+ }
47
+ }
48
+ return raw && typeof raw === "object" && !Array.isArray(raw)
49
+ ? raw
50
+ : {};
51
+ }
52
+ /**
53
+ * ⚠️ Cloudflare's `properties` are a list of `{property_id, value}` pairs, not fields, and `value`
54
+ * is a string for the scalars and an array of `{unit, price, currency}` for the price. The units are
55
+ * the provider's own wording — "per M input tokens" — so they are matched loosely and never parsed
56
+ * as a contract: an unrecognised unit costs a missing price, and a missing price shows nothing,
57
+ * which is the behaviour #257 asks for anyway.
58
+ */
59
+ const PriceEntry = z.object({
60
+ unit: z.string(),
61
+ price: z.union([z.number(), z.string()]),
62
+ currency: z.string().nullish(),
63
+ });
64
+ const ModelProperty = z.object({
65
+ property_id: z.string(),
66
+ value: z.union([z.string(), z.number(), z.boolean(), z.array(PriceEntry)]),
67
+ });
68
+ const ModelEntry = z.object({
69
+ name: z.string(),
70
+ properties: z.array(ModelProperty).nullish(),
71
+ });
72
+ // Required for the same reason `LogResponse.result` is: a body this reader does not recognise must
73
+ // not come out as "the account offers no models".
74
+ const ModelResponse = z.object({
75
+ success: z.boolean().nullish(),
76
+ result: z.array(ModelEntry).nullable(),
77
+ });
78
+ function propertyOf(properties, id) {
79
+ return properties.find((property) => property.property_id === id)?.value;
80
+ }
81
+ function priceFor(entries, side) {
82
+ const found = entries.find((entry) => {
83
+ const unit = entry.unit.toLowerCase();
84
+ return unit.includes(side) && unit.includes("token") && /\bm\b|million/.test(unit);
85
+ });
86
+ if (!found)
87
+ return undefined;
88
+ const value = typeof found.price === "string" ? Number(found.price) : found.price;
89
+ return Number.isFinite(value) ? value : undefined;
90
+ }
91
+ function readModel(entry) {
92
+ const properties = entry.properties ?? [];
93
+ const context = Number(propertyOf(properties, "context_window"));
94
+ const rawPrice = propertyOf(properties, "price");
95
+ const prices = Array.isArray(rawPrice) ? rawPrice : [];
96
+ const input = priceFor(prices, "input");
97
+ const output = priceFor(prices, "output");
98
+ return {
99
+ name: entry.name,
100
+ contextTokens: Number.isFinite(context) && context > 0 ? context : null,
101
+ // ⚠️ Both halves or neither. A model shown with an input price and no output price reads as if
102
+ // its answers were free, which is a worse statement than saying nothing.
103
+ price: input !== undefined && output !== undefined
104
+ ? { inputPerMillion: input, outputPerMillion: output }
105
+ : null,
106
+ functionCalling: String(propertyOf(properties, "function_calling") ?? "") === "true",
107
+ };
108
+ }
109
+ export function createCloudflareApi(deps) {
110
+ /**
111
+ * ⚠️ Neither the URL nor the body of a refusal is quoted onward. The URL carries the account id
112
+ * and the body carries whatever Cloudflare wrote about a token; this message is read by a person
113
+ * on a screen and by a model through the MCP surface alike. The status is kept, because it is the
114
+ * whole of what an operator can act on: 401/403 is the token's permissions, 404 is the gateway id,
115
+ * 429 is a limit.
116
+ */
117
+ async function get(path, query) {
118
+ const url = new URL(`${ApiOrigin}${path}`);
119
+ for (const [key, value] of Object.entries(query))
120
+ url.searchParams.set(key, value);
121
+ let response;
122
+ try {
123
+ response = await deps.fetch(url, {
124
+ headers: { authorization: `Bearer ${deps.token}`, accept: "application/json" },
125
+ });
126
+ }
127
+ catch {
128
+ throw new IntelError(502, "cloudflare_api_unreachable", "The Cloudflare API did not answer");
129
+ }
130
+ if (!response.ok) {
131
+ throw new IntelError(502, "cloudflare_api_refused", `The Cloudflare API refused this read (HTTP ${response.status})`);
132
+ }
133
+ return await response.json().catch(() => null);
134
+ }
135
+ return {
136
+ async gatewayCalls(query) {
137
+ const calls = [];
138
+ let partial = false;
139
+ for (let page = 1; page <= MaxPages; page += 1) {
140
+ const body = await get(`/accounts/${encodeURIComponent(deps.accountId)}/ai-gateway/gateways/${encodeURIComponent(deps.gatewayId)}/logs`, {
141
+ page: String(page),
142
+ per_page: String(PerPage),
143
+ start_date: query.since.toISOString(),
144
+ end_date: query.until.toISOString(),
145
+ order_by: "created_at",
146
+ order_by_direction: "desc",
147
+ // ⚠️ Only documented scalar parameters travel. The endpoint also takes a `filters` array
148
+ // whose query encoding Cloudflare documents nowhere — neither the reference nor the
149
+ // curl example shows it — so a guess at it would either be ignored (a slow read) or
150
+ // rejected (no read at all), and there is no way to tell those apart from the status.
151
+ // The agent is therefore picked out below, from the metadata the runtime stamped.
152
+ });
153
+ const parsed = LogResponse.safeParse(body);
154
+ // A shape this reader cannot make sense of is a failure, not an empty window: an empty
155
+ // window reads as "this agent cost nothing".
156
+ if (!parsed.success) {
157
+ throw new IntelError(502, "cloudflare_api_unreadable", "The Cloudflare API answered in a shape this version does not understand");
158
+ }
159
+ const entries = parsed.data.result ?? [];
160
+ for (const entry of entries) {
161
+ const metadata = readMetadata(entry.metadata);
162
+ if (metadata.agentId !== query.agentId)
163
+ continue;
164
+ calls.push({
165
+ runId: typeof metadata.runId === "string" ? metadata.runId : null,
166
+ model: entry.model ?? "",
167
+ cost: entry.cost ?? 0,
168
+ at: entry.created_at ?? query.until.toISOString(),
169
+ });
170
+ }
171
+ if (entries.length < PerPage)
172
+ return { calls, partial: false };
173
+ partial = page === MaxPages;
174
+ }
175
+ return { calls, partial };
176
+ },
177
+ async workersAiModels() {
178
+ const body = await get(`/accounts/${encodeURIComponent(deps.accountId)}/ai/models/search`, {
179
+ per_page: "200",
180
+ hide_experimental: "true",
181
+ });
182
+ const parsed = ModelResponse.safeParse(body);
183
+ if (!parsed.success) {
184
+ throw new IntelError(502, "cloudflare_api_unreadable", "The Cloudflare API answered in a shape this version does not understand");
185
+ }
186
+ return (parsed.data.result ?? []).map(readModel);
187
+ },
188
+ };
189
+ }
@@ -0,0 +1,64 @@
1
+ /**
2
+ * The two things Intel reads out of the Cloudflare account, and nothing else.
3
+ *
4
+ * ⚠️ Both are READS, and the port says so by having no other verb. The token behind it is
5
+ * account-wide — Cloudflare offers no per-gateway scope for `AI Gateway: Read`, the same limitation
6
+ * `AI Gateway: Run` already had in #239 — so the narrowness has to come from what this interface
7
+ * can express rather than from what the credential allows.
8
+ */
9
+ export interface CloudflareAccountApi {
10
+ /**
11
+ * The gateway's own log lines for one agent, within a window.
12
+ *
13
+ * ⚠️ `cost` here is the **debit from the Cloudflare balance**, 1:1 — measured on 2026-08-07
14
+ * against the running installation: balance $19.77 + spend $0.23 = the $20.00 that was loaded.
15
+ * Cloudflare takes its 5 % when the balance is topped up and passes inference through without a
16
+ * markup, so this number means "what this costs us" and needs no conversion. A reader who
17
+ * multiplied it by anything would be inventing a second, wrong price.
18
+ */
19
+ gatewayCalls(query: GatewayCallQuery): Promise<GatewayCallPage>;
20
+ /**
21
+ * What Cloudflare currently charges for the models it serves itself (#257).
22
+ *
23
+ * ⚠️ Workers AI only. Cloudflare publishes no price list for the Anthropic models it resells
24
+ * through Unified Billing, so those figures have no live source and stay a table — which is the
25
+ * whole reason the catalog says, per entry, where its numbers came from.
26
+ */
27
+ workersAiModels(): Promise<WorkersAiModel[]>;
28
+ }
29
+ export interface GatewayCallQuery {
30
+ /** The value stamped as `cf-aig-metadata.agentId` by the agent runtime. */
31
+ agentId: string;
32
+ since: Date;
33
+ until: Date;
34
+ }
35
+ export interface GatewayCall {
36
+ /** From `cf-aig-metadata.runId`. `null` for a call made before the stamp existed. */
37
+ runId: string | null;
38
+ model: string;
39
+ /** US dollars, as billed. */
40
+ cost: number;
41
+ at: string;
42
+ }
43
+ export interface GatewayCallPage {
44
+ calls: GatewayCall[];
45
+ /**
46
+ * The window was cut off at the page cap, so every total built from it is a floor rather than a
47
+ * total.
48
+ *
49
+ * ⚠️ It exists so the screen can say "at least". A sum that silently stopped counting is the same
50
+ * failure as a missing number pretending to be zero, only harder to notice.
51
+ */
52
+ partial: boolean;
53
+ }
54
+ export interface WorkersAiModel {
55
+ /** The full `@cf/...` id, exactly as a definition names it. */
56
+ name: string;
57
+ contextTokens: number | null;
58
+ price: {
59
+ inputPerMillion: number;
60
+ outputPerMillion: number;
61
+ } | null;
62
+ /** Whether this model can call a tool at all. An agent is a tool loop; one that cannot is useless. */
63
+ functionCalling: boolean;
64
+ }
@@ -0,0 +1,16 @@
1
+ import type { AgentCostsDeps, AgentCostsService } from "./agent-costs.types.js";
2
+ /**
3
+ * What this agent has cost, read out of Cloudflare's AI Gateway log rather than computed here.
4
+ *
5
+ * ⚠️ Nothing in this file multiplies tokens by a price, and nothing may start to. The gateway
6
+ * publishes the billed figure per call and it is the debit from the account balance, 1:1 — measured
7
+ * on 2026-08-07 (#251). A price table beside it would be a second, self-maintained answer to a
8
+ * question that already has a first one, and the day the two disagree the wrong one is the one
9
+ * on screen.
10
+ *
11
+ * ⚠️ The three checks below are in the same order as the runtime proxy's, and for the same reasons:
12
+ * the capability first so a refusal costs no storage read; the resource ACL second, because seeing
13
+ * an agent and seeing its bill are the same question; the reader last, so nobody learns whether this
14
+ * deployment holds a Cloudflare token before they are allowed to ask.
15
+ */
16
+ export declare function createAgentCosts(deps: AgentCostsDeps): AgentCostsService;
@@ -0,0 +1,105 @@
1
+ import { IntelError } from "../shared/intel-error/intel-error.js";
2
+ /**
3
+ * The windows the profile shows, longest last.
4
+ *
5
+ * Two, and both of them, because they answer different questions: thirty days is what an agent
6
+ * costs to keep, seven is whether that changed. One number alone cannot say "this got more
7
+ * expensive last week".
8
+ */
9
+ const Windows = [7, 30];
10
+ const DayMs = 24 * 60 * 60 * 1_000;
11
+ /**
12
+ * What this agent has cost, read out of Cloudflare's AI Gateway log rather than computed here.
13
+ *
14
+ * ⚠️ Nothing in this file multiplies tokens by a price, and nothing may start to. The gateway
15
+ * publishes the billed figure per call and it is the debit from the account balance, 1:1 — measured
16
+ * on 2026-08-07 (#251). A price table beside it would be a second, self-maintained answer to a
17
+ * question that already has a first one, and the day the two disagree the wrong one is the one
18
+ * on screen.
19
+ *
20
+ * ⚠️ The three checks below are in the same order as the runtime proxy's, and for the same reasons:
21
+ * the capability first so a refusal costs no storage read; the resource ACL second, because seeing
22
+ * an agent and seeing its bill are the same question; the reader last, so nobody learns whether this
23
+ * deployment holds a Cloudflare token before they are allowed to ask.
24
+ */
25
+ export function createAgentCosts(deps) {
26
+ return {
27
+ async read(actor, agentId) {
28
+ if (!actor.canRun) {
29
+ throw new IntelError(403, "permission_required", "Permission required");
30
+ }
31
+ const node = await deps.visibleNode(actor, agentId);
32
+ // Not found and not visible answer alike, as everywhere else in the tree.
33
+ if (node?.kind !== "agent") {
34
+ throw new IntelError(404, "agent_not_found", "Agent was not found");
35
+ }
36
+ if (!deps.gateway)
37
+ return empty("not_configured");
38
+ const until = deps.now();
39
+ const longest = Windows[Windows.length - 1] ?? 30;
40
+ const since = new Date(until.getTime() - longest * DayMs);
41
+ let page;
42
+ try {
43
+ page = await deps.gateway.gatewayCalls({ agentId, since, until });
44
+ }
45
+ catch {
46
+ // ⚠️ Swallowed on purpose, and named rather than rethrown. The run list beside these figures
47
+ // is answered by the runtime and is perfectly readable; letting a Cloudflare outage take the
48
+ // whole screen down would hide the tokens too — and the tokens are the half that never
49
+ // depended on Cloudflare being reachable.
50
+ return empty("unreadable");
51
+ }
52
+ return {
53
+ status: "read",
54
+ currency: "USD",
55
+ runs: perRun(page.calls),
56
+ windows: Windows.map((days) => window(days, page.calls, until)),
57
+ partial: page.partial,
58
+ };
59
+ },
60
+ };
61
+ }
62
+ function empty(status) {
63
+ return {
64
+ status,
65
+ currency: "USD",
66
+ // ⚠️ Empty, and the status is what stops it reading as "free". Nothing here invents a zero:
67
+ // `runs: []` with `status: "read"` really does mean this agent has cost nothing yet.
68
+ runs: [],
69
+ windows: Windows.map((days) => ({ days, cost: 0, calls: 0, models: [] })),
70
+ partial: false,
71
+ };
72
+ }
73
+ function perRun(calls) {
74
+ const byRun = new Map();
75
+ for (const call of calls) {
76
+ // A call the runtime could not stamp — anything from before #251 — belongs to no run and is
77
+ // dropped from the per-run view. It still counts towards the windows below: it is the agent's
78
+ // money either way, and only its place in the list is unknown.
79
+ if (!call.runId)
80
+ continue;
81
+ const known = byRun.get(call.runId);
82
+ if (known) {
83
+ known.cost += call.cost;
84
+ known.calls += 1;
85
+ continue;
86
+ }
87
+ byRun.set(call.runId, { runId: call.runId, cost: call.cost, calls: 1 });
88
+ }
89
+ return [...byRun.values()];
90
+ }
91
+ function window(days, calls, until) {
92
+ const from = until.getTime() - days * DayMs;
93
+ const inside = calls.filter((call) => {
94
+ const at = Date.parse(call.at);
95
+ // An unparseable timestamp counts towards the longest window rather than being dropped: the
96
+ // money was spent, and a total that quietly omits it is the wrong kind of wrong.
97
+ return Number.isNaN(at) ? days === Windows[Windows.length - 1] : at >= from;
98
+ });
99
+ return {
100
+ days,
101
+ cost: inside.reduce((total, call) => total + call.cost, 0),
102
+ calls: inside.length,
103
+ models: [...new Set(inside.map((call) => call.model).filter((model) => model.length > 0))],
104
+ };
105
+ }
@@ -0,0 +1,30 @@
1
+ import type { AgentCosts, Node } from "@anchrd/intel-contract";
2
+ import type { CloudflareAccountApi } from "../adapters/cloudflare-api/cloudflare-api.types.js";
3
+ import type { AgentActor } from "../agent-runtime/agent-runtime.types.js";
4
+ import type { Actor } from "../nodes/nodes.types.js";
5
+ export interface AgentCostsDeps {
6
+ /**
7
+ * Absent where the deployment configured no account id, gateway id or read token.
8
+ *
9
+ * ⚠️ Absent is a NAMED state and never an error: an installation that never wants Intel to hold a
10
+ * Cloudflare token is a supported installation, and it should read "not configured" rather than
11
+ * a 502 somebody spends an afternoon on.
12
+ */
13
+ gateway?: Pick<CloudflareAccountApi, "gatewayCalls">;
14
+ /**
15
+ * The tree's own visibility lookup, unchanged on the way through — the same one the runtime proxy
16
+ * uses. What an agent spends is as sensitive as what it does, and neither answer may be kinder
17
+ * than the other.
18
+ */
19
+ visibleNode(actor: Actor, nodeId: string): Promise<Node | null>;
20
+ now(): Date;
21
+ }
22
+ export interface AgentCostsService {
23
+ /**
24
+ * ⚠️ The answer shape lives in `@anchrd/intel-contract` and not here, because the browser parses
25
+ * it. A second declaration in this package is how the screen and the service start disagreeing
26
+ * about what `status` can say — and `status` is the field that separates "cost nothing" from "not
27
+ * known" (#251).
28
+ */
29
+ read(actor: AgentActor, agentId: string): Promise<AgentCosts>;
30
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -1,3 +1,4 @@
1
+ import { UI_LANGUAGES } from "@anchrd/intel-contract";
1
2
  import { z } from "zod";
2
3
  const UiConfig = z.strictObject({
3
4
  theme: z.string().min(1).optional(),
@@ -100,8 +101,12 @@ export function createBuild(deps) {
100
101
  }
101
102
  catalogs[language] = candidateParsed.data;
102
103
  }
103
- if (config.defaultLanguage !== "en" && !(config.defaultLanguage in catalogs)) {
104
- throw new Error(`ui.defaultLanguage ${JSON.stringify(config.defaultLanguage)} is not listed in ui.languages`);
104
+ // A built-in language needs no entry in ui.languages — its catalog is already in the UI.
105
+ // Without this exception, `ui.defaultLanguage: "de"` would force the customer to list a copy
106
+ // of de.json that rots at every UI update, and nobody maintains that copy.
107
+ const builtIn = UI_LANGUAGES;
108
+ if (!builtIn.includes(config.defaultLanguage) && !(config.defaultLanguage in catalogs)) {
109
+ throw new Error(`ui.defaultLanguage ${JSON.stringify(config.defaultLanguage)} is neither built in (${UI_LANGUAGES.join(", ")}) nor listed in ui.languages`);
105
110
  }
106
111
  const theme = config.theme
107
112
  ? `/* Generated by \`intel build\` from ${asComment(config.theme)}. */\n${await readRequired(config.theme, "ui.theme")}\n`
@@ -3,6 +3,7 @@ import { Unzip, UnzipInflate, Zip, ZipDeflate, ZipPassThrough } from "fflate";
3
3
  import { documentLinkTargets } from "../nodes/document-links/document-links.js";
4
4
  import { parseCsv } from "../shared/csv/csv.js";
5
5
  import { IntelError } from "../shared/intel-error/intel-error.js";
6
+ import { plainTitle } from "../shared/plain-title/plain-title.js";
6
7
  // What every bundle leaves out, by decision rather than by accident (#136). The manifest says so,
7
8
  // because a backup that is silent about what it does not hold will be trusted with exactly that.
8
9
  const Excluded = ["version-history", "grants", "flow-runs", "archived-nodes"];
@@ -866,7 +867,7 @@ export function createBundle(deps) {
866
867
  flows.push({
867
868
  id: entry.newId,
868
869
  parentId,
869
- title: entry.title,
870
+ title: plainTitle(entry.title),
870
871
  description: entry.description,
871
872
  ownerId: actor.id,
872
873
  currentVersionId: versionId,
@@ -953,7 +954,9 @@ export function createBundle(deps) {
953
954
  parentId,
954
955
  // Flows took the `continue` above; what reaches here is one of the five node kinds.
955
956
  kind: entry.kind,
956
- title: entry.title,
957
+ // A manifest title and a file name are both somebody else's text, and an export written
958
+ // by an escaping chain carries the entity in both (#202).
959
+ title: plainTitle(entry.title),
957
960
  description: entry.description,
958
961
  ownerId: actor.id,
959
962
  currentVersionId: version?.id ?? null,
@@ -1,5 +1,6 @@
1
1
  import { flowNodeLayer } from "@anchrd/intel-contract";
2
2
  import { IntelError } from "../shared/intel-error/intel-error.js";
3
+ import { plainTitle } from "../shared/plain-title/plain-title.js";
3
4
  function invalid(detail) {
4
5
  throw new IntelError(400, "flow_graph_invalid", detail);
5
6
  }
@@ -1071,7 +1072,7 @@ export function createFlows(deps) {
1071
1072
  flow: {
1072
1073
  id: deps.id(),
1073
1074
  parentId: input.parentId,
1074
- title: input.title,
1075
+ title: plainTitle(input.title),
1075
1076
  description: input.description,
1076
1077
  ownerId: actor.id,
1077
1078
  currentVersionId: null,
@@ -1099,7 +1100,7 @@ export function createFlows(deps) {
1099
1100
  flow: {
1100
1101
  ...current,
1101
1102
  parentId: input.parentId === undefined ? current.parentId : input.parentId,
1102
- title: input.title ?? current.title,
1103
+ title: input.title === undefined ? current.title : plainTitle(input.title),
1103
1104
  description: input.description === undefined ? current.description : input.description,
1104
1105
  updatedAt: deps.now().toISOString(),
1105
1106
  },
package/dist/http/http.js CHANGED
@@ -144,6 +144,26 @@ export function createHttp(deps) {
144
144
  app.get("/capabilities", (context) => {
145
145
  return context.json({ agentRuntime: deps.agents.available() });
146
146
  });
147
+ /**
148
+ * What the models on offer cost and how much they hold (#257).
149
+ *
150
+ * ⚠️ Intel asks Cloudflare, never the browser. The account endpoint needs a token and a token does
151
+ * not belong in a SPA — which is the sentence the hard-coded table in `packages/ui` used to carry
152
+ * as the reason it stayed hard-coded. This route is the answer to it: the figures cross the wire,
153
+ * the credential does not.
154
+ *
155
+ * ⚠️ No capability beyond being signed in. It is a fact about the installation and about nobody's
156
+ * data — the same footing `/capabilities` stands on.
157
+ *
158
+ * ⚠️ The `cache-control` is the caching the ticket asks for, and it is the caching that matters:
159
+ * the waste it names is "one fetch every time the select is opened", and that fetch is the
160
+ * browser's. A Worker isolate has nowhere durable to keep a table between requests, and inventing
161
+ * storage for a list of prices would cost more than the request it saved.
162
+ */
163
+ app.get("/models", async (context) => {
164
+ context.header("cache-control", "private, max-age=3600");
165
+ return context.json(await deps.models.list());
166
+ });
147
167
  /**
148
168
  * The agent runtime, reached through Intel.
149
169
  *
@@ -171,6 +191,23 @@ export function createHttp(deps) {
171
191
  { method: "POST", pattern: "/resume", path: () => "/resume" },
172
192
  { method: "POST", pattern: "/run", path: () => "/run" },
173
193
  ];
194
+ /**
195
+ * What this agent has cost — answered by Intel itself, not forwarded (#251).
196
+ *
197
+ * ⚠️ It stands under `/agents/:agentId` and is NOT in `runtimeRoutes`, which is the whole point of
198
+ * writing it out here. The runtime has no idea what anything costs: the figures come from
199
+ * Cloudflare's AI Gateway log, which needs an `AI Gateway: Read` token, and that token belongs in
200
+ * Intel rather than in the Worker that executes model-driven tool calls — the runtime's own
201
+ * `CLAUDE.md` keeps the number of credentials there deliberately small. The runtime's part is the
202
+ * stamp on the model call and nothing more.
203
+ *
204
+ * ⚠️ `agents/run`, matching the run list this stands beside. What an agent spends is as sensitive
205
+ * as what it did, and the two must not be reachable on different terms.
206
+ */
207
+ app.get("/agents/:agentId/costs", async (context) => {
208
+ const authorization = context.get("authorization");
209
+ return context.json(await deps.agentCosts.read(asAgentActor(authorization), context.req.param("agentId")));
210
+ });
174
211
  for (const route of runtimeRoutes) {
175
212
  app.on(route.method, `/agents/:agentId${route.pattern}`, async (context) => {
176
213
  const authorization = context.get("authorization");
@@ -1,8 +1,10 @@
1
1
  import type { GateClient } from "@anchrd/gate-sdk";
2
+ import type { AgentCostsService } from "../agent-costs/agent-costs.types.js";
2
3
  import type { AgentRuntimeService } from "../agent-runtime/agent-runtime.types.js";
3
4
  import type { BrowserAuth } from "../auth/auth.types.js";
4
5
  import type { BundleService } from "../bundle/bundle.types.js";
5
6
  import type { FlowService } from "../flows/flows.types.js";
7
+ import type { ModelCatalogService } from "../model-catalog/model-catalog.types.js";
6
8
  import type { NodeService } from "../nodes/nodes.types.js";
7
9
  import type { ToolService } from "../tools/tools.types.js";
8
10
  export interface HttpDeps {
@@ -12,6 +14,10 @@ export interface HttpDeps {
12
14
  tools: ToolService;
13
15
  bundle: BundleService;
14
16
  agents: AgentRuntimeService;
17
+ /** What an agent has cost, read out of the AI Gateway log rather than computed here (#251). */
18
+ agentCosts: AgentCostsService;
19
+ /** What the models on offer cost, read from Cloudflare rather than typed out here (#257). */
20
+ models: ModelCatalogService;
15
21
  resource: string;
16
22
  resourceMetadataUrl: string;
17
23
  auth?: Pick<BrowserAuth, "resolve">;
@@ -73,6 +73,8 @@ export function createIntel(deps) {
73
73
  tools: deps.tools,
74
74
  bundle: deps.bundle,
75
75
  agents: deps.agents,
76
+ agentCosts: deps.agentCosts,
77
+ models: deps.models,
76
78
  resource,
77
79
  resourceMetadataUrl,
78
80
  ...(deps.auth ? { auth: deps.auth } : {}),
@@ -91,6 +93,7 @@ export function createIntel(deps) {
91
93
  // returned a credential beside its answer would be one somebody logs.
92
94
  bearer: bearer(context.req.raw.headers) ?? "",
93
95
  agents: deps.agents,
96
+ agentCosts: deps.agentCosts,
94
97
  flows: deps.flows,
95
98
  nodes: deps.nodes,
96
99
  tools: deps.tools,
@@ -1,8 +1,10 @@
1
1
  import type { GateClient } from "@anchrd/gate-sdk";
2
+ import type { AgentCostsService } from "../agent-costs/agent-costs.types.js";
2
3
  import type { AgentRuntimeService } from "../agent-runtime/agent-runtime.types.js";
3
4
  import type { BrowserAuth } from "../auth/auth.types.js";
4
5
  import type { BundleService } from "../bundle/bundle.types.js";
5
6
  import type { FlowService } from "../flows/flows.types.js";
7
+ import type { ModelCatalogService } from "../model-catalog/model-catalog.types.js";
6
8
  import type { NodeService } from "../nodes/nodes.types.js";
7
9
  import type { ToolService } from "../tools/tools.types.js";
8
10
  export interface IntelDeps {
@@ -14,5 +16,7 @@ export interface IntelDeps {
14
16
  tools: ToolService;
15
17
  bundle: BundleService;
16
18
  agents: AgentRuntimeService;
19
+ agentCosts: AgentCostsService;
20
+ models: ModelCatalogService;
17
21
  auth?: BrowserAuth;
18
22
  }
package/dist/mcp/mcp.js CHANGED
@@ -912,6 +912,29 @@ export async function handleMcp(request, deps) {
912
912
  path: "/resume",
913
913
  body: "{}",
914
914
  })));
915
+ /**
916
+ * ⚠️ The MCP half of the profile's cost figures, in the same delivery slice — the repository's
917
+ * "every user-visible capability has an Intel MCP equivalent" rule.
918
+ *
919
+ * ⚠️ Under `agents/run` beside pause and resume, not under `knowledge/read`. What an agent
920
+ * spends is as sensitive as what it did, and this answer stands beside the run list rather than
921
+ * beside the node.
922
+ *
923
+ * ⚠️ `status` travels with the numbers and is not flattened away. A client that read an empty
924
+ * `runs` list as "this agent is free" would be reporting a Cloudflare outage as a saving.
925
+ */
926
+ server.registerTool("agent_costs", {
927
+ title: "Agent costs",
928
+ description: "What one agent has actually cost, read from Cloudflare's AI Gateway log: per run, and as a total over the last 7 and 30 days. `status` says whether the numbers were read at all — `not_configured` and `unreadable` both mean no figures, never zero cost. `partial` means the read hit its page limit, so the totals are a floor.",
929
+ inputSchema: PauseAgentInput,
930
+ annotations: {
931
+ title: "Agent costs",
932
+ readOnlyHint: true,
933
+ destructiveHint: false,
934
+ idempotentHint: true,
935
+ openWorldHint: false,
936
+ },
937
+ }, async (input) => text(await deps.agentCosts.read(agentActor, input.nodeId)));
915
938
  server.registerTool("agent_run_now", {
916
939
  title: "Run agent now",
917
940
  description: "Fire one of the agent's own schedules straight away, against the document or flow it names. The run appears in the agent's run list; a target the agent does not schedule is refused.",
@@ -1,4 +1,5 @@
1
1
  import type { Authorized } from "@anchrd/gate-sdk";
2
+ import type { AgentCostsService } from "../agent-costs/agent-costs.types.js";
2
3
  import type { AgentRuntimeService } from "../agent-runtime/agent-runtime.types.js";
3
4
  import type { BundleService } from "../bundle/bundle.types.js";
4
5
  import type { FlowService } from "../flows/flows.types.js";
@@ -16,4 +17,6 @@ export interface McpDeps {
16
17
  tools: ToolService;
17
18
  bundle: BundleService;
18
19
  agents: AgentRuntimeService;
20
+ /** What an agent has cost, out of the AI Gateway log — the MCP half of the profile's figures (#251). */
21
+ agentCosts: AgentCostsService;
19
22
  }
@@ -0,0 +1,2 @@
1
+ import type { ModelCatalogDeps, ModelCatalogService } from "./model-catalog.types.js";
2
+ export declare function createModelCatalog(deps: ModelCatalogDeps): ModelCatalogService;
@@ -0,0 +1,99 @@
1
+ /**
2
+ * The figures this package knows without asking anybody, and the ONLY place they are written down.
3
+ *
4
+ * ⚠️ It moved here out of `packages/ui` with #257, and the move is the point rather than the
5
+ * tidying. The comment it used to carry accused itself correctly — *"it is a copy and it ages
6
+ * silently"* — and named the reason it stayed a copy: the Cloudflare endpoint needs a token, and a
7
+ * token does not belong in a SPA. It belongs here. What the browser gets is the answer, never the
8
+ * credential, and the Workers AI half of this table is overwritten by the live one on every read.
9
+ *
10
+ * ⚠️ The Anthropic entries have no live source and will not get one. Cloudflare resells those models
11
+ * through Unified Billing and publishes no price list for them, so they stay `builtin` even on a
12
+ * perfectly healthy read — which is exactly why `source` is per entry and not per response.
13
+ *
14
+ * ⚠️ Workers AI models without `function_calling` are deliberately absent from the live merge. An
15
+ * agent is a tool loop; a model that cannot call a tool cannot run one.
16
+ *
17
+ * Collected 2026-08-06 from `GET /accounts/{id}/ai/models/search` and from Anthropic's model list.
18
+ */
19
+ const builtin = [
20
+ entry("anthropic", "claude-opus-5", "Opus 5", 1_000_000, 5, 25),
21
+ entry("anthropic", "claude-sonnet-5", "Sonnet 5", 1_000_000, 3, 15),
22
+ entry("anthropic", "claude-sonnet-4", "Sonnet 4", 200_000, 3, 15),
23
+ entry("anthropic", "claude-haiku-4-5", "Haiku 4.5", 200_000, 1, 5),
24
+ entry("workers-ai", "@cf/openai/gpt-oss-120b", "GPT-OSS 120B", 128_000, 0.35, 0.75),
25
+ entry("workers-ai", "@cf/openai/gpt-oss-20b", "GPT-OSS 20B", 128_000, 0.2, 0.3),
26
+ entry("workers-ai", "@cf/moonshotai/kimi-k2.6", "Kimi K2.6", 262_144, 0.95, 4),
27
+ entry("workers-ai", "@cf/moonshotai/kimi-k2.7-code", "Kimi K2.7 Code", 262_144, 0.95, 4),
28
+ entry("workers-ai", "@cf/zai-org/glm-5.2", "GLM 5.2", 262_144, 1.4, 4.4),
29
+ entry("workers-ai", "@cf/zai-org/glm-4.7-flash", "GLM 4.7 Flash", 131_072, 0.0605, 0.4),
30
+ entry("workers-ai", "@cf/google/gemma-4-26b-a4b-it", "Gemma 4 26B", 256_000, 0.1, 0.3),
31
+ entry("workers-ai", "@cf/nvidia/nemotron-3-120b-a12b", "Nemotron 3 120B", 256_000, 0.5, 1.5),
32
+ entry("workers-ai", "@cf/meta/llama-4-scout-17b-16e-instruct", "Llama 4 Scout", 131_000, 0.27, 0.85),
33
+ entry("workers-ai", "@cf/meta/llama-3.3-70b-instruct-fp8-fast", "Llama 3.3 70B Fast", 24_000, 0.293, 2.253),
34
+ entry("workers-ai", "@cf/mistralai/mistral-small-3.1-24b-instruct", "Small 3.1 24B", 128_000, 0.351, 0.555),
35
+ entry("workers-ai", "@cf/ibm-granite/granite-4.0-h-micro", "Granite 4.0 Micro", 131_000, 0.017, 0.112),
36
+ entry("workers-ai", "@cf/qwen/qwen3-30b-a3b-fp8", "Qwen3 30B", 32_768, 0.0509, 0.335),
37
+ ];
38
+ function entry(provider, model, name, contextTokens, inputPerMillion, outputPerMillion) {
39
+ return {
40
+ provider,
41
+ model,
42
+ name,
43
+ contextTokens,
44
+ price: { inputPerMillion, outputPerMillion },
45
+ source: "builtin",
46
+ };
47
+ }
48
+ /**
49
+ * The model's own name, taken from its id rather than invented.
50
+ *
51
+ * A live Workers AI model that is not in the table above has no written-out name anywhere, and
52
+ * "@cf/qwen/qwen3-30b-a3b-fp8" prettified by any rule short enough to write here comes out wrong —
53
+ * so the last segment of the id is used as it stands. A raw name is easier to recognise as raw than
54
+ * a wrong one is to recognise as wrong.
55
+ */
56
+ function nameFromId(model) {
57
+ const segments = model.split("/");
58
+ return segments[segments.length - 1] ?? model;
59
+ }
60
+ export function createModelCatalog(deps) {
61
+ return {
62
+ async list() {
63
+ if (!deps.cloudflare)
64
+ return { entries: builtin, liveStatus: "not_configured" };
65
+ try {
66
+ const live = await deps.cloudflare.workersAiModels();
67
+ const merged = new Map(builtin.map((known) => [`${known.provider}:${known.model}`, known]));
68
+ for (const model of live) {
69
+ // ⚠️ A model that cannot call a tool is skipped rather than listed without figures. It
70
+ // would be an offer that produces a broken agent for whoever took it.
71
+ if (!model.functionCalling)
72
+ continue;
73
+ const key = `workers-ai:${model.name}`;
74
+ const known = merged.get(key);
75
+ merged.set(key, {
76
+ provider: "workers-ai",
77
+ model: model.name,
78
+ name: known?.name ?? nameFromId(model.name),
79
+ // ⚠️ The live figure wins, and a live figure that is MISSING wins too — falling back to
80
+ // the table for one of the two halves would produce an entry that is half fresh and
81
+ // half years old, labelled fresh. Either the account answered for this model or it did
82
+ // not.
83
+ contextTokens: model.contextTokens,
84
+ price: model.price,
85
+ source: "cloudflare",
86
+ });
87
+ }
88
+ return { entries: [...merged.values()], liveStatus: "read" };
89
+ }
90
+ catch {
91
+ // ⚠️ Named, not thrown. The select has to render either way — an agent whose model cannot be
92
+ // chosen is an agent nobody can repair from that screen — and the alternative to a stale
93
+ // price here is no price at all, which helps nobody choose. What the caller gets is the
94
+ // table plus the reason it is the table, and the screen says so.
95
+ return { entries: builtin, liveStatus: "unreadable" };
96
+ }
97
+ },
98
+ };
99
+ }
@@ -0,0 +1,15 @@
1
+ import type { ModelCatalog } from "@anchrd/intel-contract";
2
+ import type { CloudflareAccountApi } from "../adapters/cloudflare-api/cloudflare-api.types.js";
3
+ export interface ModelCatalogDeps {
4
+ /** Absent where the deployment configured no Cloudflare read token. A named state, not an error. */
5
+ cloudflare?: Pick<CloudflareAccountApi, "workersAiModels">;
6
+ }
7
+ export interface ModelCatalogService {
8
+ /**
9
+ * ⚠️ The answer shape lives in `@anchrd/intel-contract`, because the select in the browser parses
10
+ * it. Its `source` field is per ENTRY on purpose: Cloudflare publishes figures for the models it
11
+ * serves itself and none for the Anthropic models it resells, so a healthy read still leaves half
12
+ * the list on a written-out table (#257).
13
+ */
14
+ list(): Promise<ModelCatalog>;
15
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -1,6 +1,7 @@
1
- import { AgentDefinition, AgentMediaType, TableMediaType, } from "@anchrd/intel-contract";
1
+ import { AgentDefinition, AgentMediaType, agentReferenceAccepts, TableMediaType, } from "@anchrd/intel-contract";
2
2
  import { encodeCsv, parseCsv } from "../shared/csv/csv.js";
3
3
  import { IntelError } from "../shared/intel-error/intel-error.js";
4
+ import { plainTitle } from "../shared/plain-title/plain-title.js";
4
5
  import { documentLinkTargets } from "./document-links/document-links.js";
5
6
  // ⚠️ The R2 key of a version written before #125 begins `knowledge/`, and it stays that way. A key
6
7
  // is stored in `node_versions.content_key` and read back from there; nothing derives one from ids,
@@ -327,6 +328,36 @@ export function createNodes(deps) {
327
328
  throw new IntelError(500, "content_missing", "Version content is missing");
328
329
  return { node, version, definition: parseStoredDefinition(body), applicationId };
329
330
  }
331
+ /**
332
+ * Whether every reference names a node its role can actually use (#255).
333
+ *
334
+ * ⚠️ Here and not in the screen. The screen offers only what a role accepts, but a definition can
335
+ * be written straight over the Intel MCP surface — and a `memory` reference pointing at a
336
+ * document is not a cosmetic mistake: `agent_remember` writes into what it is given, so the agent
337
+ * would overwrite the document somebody handed it.
338
+ *
339
+ * ⚠️ A reference to a node this actor cannot see is left alone, deliberately. Refusing it would
340
+ * answer "there is a node with that id and it is the wrong kind" to somebody who may not know the
341
+ * node exists (#41), and accepting it changes nothing: the runtime reads with the AGENT's grants,
342
+ * and what it may not read it reports as an unavailable reference. What is checked is what the
343
+ * writer can see, which is exactly what they chose.
344
+ *
345
+ * ⚠️ Ids are deduplicated before the reads. The contract allows two hundred references and the
346
+ * same folder twice; one lookup per row would make a large definition pay for the repetition.
347
+ */
348
+ async function checkReferences(actor, definition) {
349
+ const kinds = new Map();
350
+ for (const reference of definition.references) {
351
+ let kind = kinds.get(reference.nodeId);
352
+ if (kind === undefined) {
353
+ kind = (await deps.repository.getVisible(actor, reference.nodeId))?.kind ?? null;
354
+ kinds.set(reference.nodeId, kind);
355
+ }
356
+ if (kind !== null && !agentReferenceAccepts(reference.role, kind)) {
357
+ throw new IntelError(422, "agent_reference_kind_not_allowed", `A ${kind} cannot be used as ${reference.role}`);
358
+ }
359
+ }
360
+ }
330
361
  /**
331
362
  * The one place a caller's asked-for definition becomes the definition Intel stores (D30).
332
363
  *
@@ -635,7 +666,7 @@ export function createNodes(deps) {
635
666
  id: deps.id(),
636
667
  parentId: input.parentId,
637
668
  kind: input.kind,
638
- title: input.title,
669
+ title: plainTitle(input.title),
639
670
  description: input.description,
640
671
  ownerId: actor.id,
641
672
  currentVersionId: null,
@@ -765,15 +796,20 @@ export function createNodes(deps) {
765
796
  throw new IntelError(403, "node_forbidden", "Parent folder cannot be edited");
766
797
  }
767
798
  // Before Gate, for the same reason the runtime check is before Gate: a delegation the saver
768
- // cannot back must not leave a machine principal behind for an agent that was never created.
799
+ // cannot back or a reference no role can use (#255) must not leave a machine principal
800
+ // behind for an agent that was never created.
801
+ await checkReferences(actor, input.definition);
769
802
  const definition = await delegationOf(actor, input.definition);
770
803
  const nodeId = deps.id();
804
+ // One title for both, and it is the unescaped one: Gate's name is minted before the node row
805
+ // and can never be brought back into line with it afterwards (#202).
806
+ const title = plainTitle(input.title);
771
807
  // The Application's name is what a person reads in Gate's list, so it has to be enough to
772
808
  // recognise the agent by. Title alone would leave two agents called "Research" indis-
773
809
  // tinguishable; the node ID is what the runtime's key map is keyed by anyway.
774
810
  const application = await deps.applications.create({
775
811
  token: caller.token,
776
- name: `Intel agent ${input.title} (${nodeId})`,
812
+ name: `Intel agent ${title} (${nodeId})`,
777
813
  });
778
814
  // ⚠️ The handover happens BEFORE the node is written, for the same reason Gate is asked before
779
815
  // it: at this moment nothing exists on Intel's side, so a runtime that does not take the key
@@ -799,7 +835,7 @@ export function createNodes(deps) {
799
835
  id: nodeId,
800
836
  parentId: input.parentId,
801
837
  kind: "agent",
802
- title: input.title,
838
+ title,
803
839
  description: input.description,
804
840
  ownerId: actor.id,
805
841
  currentVersionId: null,
@@ -921,6 +957,7 @@ export function createNodes(deps) {
921
957
  if (node.currentVersionId !== input.baseVersionId) {
922
958
  throw new IntelError(409, "version_conflict", "A newer version already exists");
923
959
  }
960
+ await checkReferences(actor, input.definition);
924
961
  const saved = await writeAgentVersion(actor, node, await delegationOf(actor, input.definition), input.baseVersionId, input.idempotencyKey);
925
962
  await deps.agentSchedules.sync({ token: caller.token, agentId: node.id });
926
963
  return saved;
@@ -1192,7 +1229,7 @@ export function createNodes(deps) {
1192
1229
  node: {
1193
1230
  ...current,
1194
1231
  parentId: input.parentId === undefined ? current.parentId : input.parentId,
1195
- title: input.title ?? current.title,
1232
+ title: input.title === undefined ? current.title : plainTitle(input.title),
1196
1233
  description: input.description === undefined ? current.description : input.description,
1197
1234
  updatedAt,
1198
1235
  },
@@ -0,0 +1,16 @@
1
+ /**
2
+ * ⚠️ A title is plain text, and an HTML entity in one is never what somebody meant.
3
+ *
4
+ * Intel escapes nothing, anywhere — the entity arrives already in the value (#202). The chain that
5
+ * carries a title to a write can HTML-escape what it hands to a model: the GitHub MCP server, for
6
+ * one, returns every string with `&` as `&amp;` and `"` as `&#34;`, so a model that copies a name
7
+ * out of one tool result into the next call writes the entity, not the character. Refusing the
8
+ * write would be the louder answer and the wrong one — the model reads the same escaped value
9
+ * again on its retry and cannot get past it. So the write boundary accepts it and stores what was
10
+ * meant.
11
+ *
12
+ * Repeated until it stops changing rather than once, because that is the failure the ticket names:
13
+ * a title read back escaped and written again is escaped twice, and a single round would leave
14
+ * `&amp;amp;` as `&amp;` — still an entity, still spreading.
15
+ */
16
+ export declare function plainTitle(title: string): string;
@@ -0,0 +1,45 @@
1
+ // Exactly what an HTML escaper emits, and nothing else. `&#34;` and `&#39;` are Go's
2
+ // `html.EscapeString` spelling of the quotes — the fingerprint of the tool that produced the
3
+ // titles this exists for. A general entity table is deliberately not here: `&nbsp;` or `&auml;` in
4
+ // a title is a person's choice, an escaped `&` never is.
5
+ const Escapes = [
6
+ [/&lt;/g, "<"],
7
+ [/&gt;/g, ">"],
8
+ [/&quot;/g, '"'],
9
+ [/&#0*34;/g, '"'],
10
+ [/&apos;/g, "'"],
11
+ [/&#0*39;/g, "'"],
12
+ // Last in the pass, so one round of unescaping is the exact inverse of one round of escaping:
13
+ // `&amp;lt;` becomes `&lt;` here and only the NEXT round turns it into `<`.
14
+ [/&amp;/g, "&"],
15
+ ];
16
+ // A title that survived one more escaping than any writer intended. Eight is far past anything a
17
+ // real chain produces and stops a crafted title from spinning here.
18
+ const MaxRounds = 8;
19
+ /**
20
+ * ⚠️ A title is plain text, and an HTML entity in one is never what somebody meant.
21
+ *
22
+ * Intel escapes nothing, anywhere — the entity arrives already in the value (#202). The chain that
23
+ * carries a title to a write can HTML-escape what it hands to a model: the GitHub MCP server, for
24
+ * one, returns every string with `&` as `&amp;` and `"` as `&#34;`, so a model that copies a name
25
+ * out of one tool result into the next call writes the entity, not the character. Refusing the
26
+ * write would be the louder answer and the wrong one — the model reads the same escaped value
27
+ * again on its retry and cannot get past it. So the write boundary accepts it and stores what was
28
+ * meant.
29
+ *
30
+ * Repeated until it stops changing rather than once, because that is the failure the ticket names:
31
+ * a title read back escaped and written again is escaped twice, and a single round would leave
32
+ * `&amp;amp;` as `&amp;` — still an entity, still spreading.
33
+ */
34
+ export function plainTitle(title) {
35
+ let current = title;
36
+ for (let round = 0; round < MaxRounds; round += 1) {
37
+ let next = current;
38
+ for (const [pattern, character] of Escapes)
39
+ next = next.replace(pattern, character);
40
+ if (next === current)
41
+ break;
42
+ current = next;
43
+ }
44
+ return current;
45
+ }
@@ -15,9 +15,10 @@ export const ServerDirectoryTool = "portal_list_servers";
15
15
  *
16
16
  * ⚠️ Only the shape is assumed, never the vocabulary. The portal is somebody else's product and its
17
17
  * payload is not part of any contract Intel owns, so every field is optional and the answer is
18
- * accepted from `structuredContent`, from `{ servers: [...] }` or from a JSON text block. What
19
- * makes a row usable is not that it parsed but that its identifier is confirmed against the live
20
- * tool list below.
18
+ * accepted from `structuredContent`, from `{ servers: [...] }`, from a JSON text block, or — the
19
+ * form the live portal actually uses from the prose block `proseRows` reads. What makes a row
20
+ * usable is not that it parsed but that its identifier is confirmed against the live tool list
21
+ * below.
21
22
  */
22
23
  const DirectoryRow = z.looseObject({
23
24
  id: z.string().optional(),
@@ -26,10 +27,71 @@ const DirectoryRow = z.looseObject({
26
27
  });
27
28
  const DirectoryRows = z.array(DirectoryRow);
28
29
  const DirectoryEnvelope = z.looseObject({ servers: DirectoryRows });
30
+ /** A line the portal meant as a directory entry: `- <display name> (<handle>): <state>`. */
31
+ const ProseRow = /^\s*[-*•]\s+(.+?)\s*\(([^()\s]+)\)\s*:\s*(.*)$/;
32
+ /** What claims to be an entry at all. Everything else in the block is a heading or a hint. */
33
+ const ProseBullet = /^\s*[-*•]\s/;
34
+ /**
35
+ * ⚠️ Never the ✓. A tick is decoration: a different locale, a plain-text client or a later version
36
+ * of the portal writes the same fact another way, and a reading that hangs on one code point would
37
+ * silently switch every server off. What is read is the word, and the word alone.
38
+ *
39
+ * It has to be the WHOLE state, not a word inside it, because "disabled" contains "enabled" and
40
+ * "not enabled" is built from it — a substring test would read both as switched on, and that is the
41
+ * one direction this must never get wrong. Anything else the portal might write ("aktiviert",
42
+ * "enabled (3 tools)") is therefore not offerable, and stays declared.
43
+ */
44
+ function readsAsEnabled(state) {
45
+ const words = state.toLowerCase().match(/\p{L}+/gu);
46
+ return words !== null && words.length === 1 && words[0] === "enabled";
47
+ }
48
+ /**
49
+ * The portal's prose form: a heading, one bullet per server, a closing hint. It is what the live
50
+ * portal actually answers (#233), and it is read only after every JSON reading in `rows` has failed.
51
+ *
52
+ * ⚠️ This reading hangs on somebody else's wording and can break without warning. The portal owes
53
+ * Intel no format at all, so a reworded state, a translated page or a changed layout is a normal
54
+ * event here rather than a defect. Two things keep that from becoming a quiet widening:
55
+ *
56
+ * - **A bullet that does not read as a row refuses the whole answer.** Skipping it would hand back
57
+ * a directory with one server missing, and a missing row is not a smaller list — it lets that
58
+ * server's tools fall back onto the shorter handle that encloses them, which is exactly the
59
+ * widening the split between `declared` and `servers` exists to prevent. The price is that a
60
+ * portal which one day bullets its closing hint fails loudly with the same closed
61
+ * `tool_servers_unavailable` that #233 found; that is the intended direction.
62
+ * - **Nothing read here can invent a server.** A handle no live tool confirms is never offered, so
63
+ * a misread line costs an offer, never a delegation.
64
+ */
65
+ function proseRows(answer) {
66
+ const parsed = [];
67
+ for (const block of answer.content) {
68
+ if (typeof block !== "object" || block === null || !("text" in block))
69
+ continue;
70
+ const text = block.text;
71
+ if (typeof text !== "string")
72
+ continue;
73
+ for (const line of text.split("\n")) {
74
+ if (!ProseBullet.test(line))
75
+ continue;
76
+ const [, name, handle, state] = ProseRow.exec(line) ?? [];
77
+ if (name === undefined || handle === undefined)
78
+ return null;
79
+ // `id` carries the handle and `name` the display name, but neither is believed on its own:
80
+ // both stay candidates in `toolServersFrom`, and the live tool list decides which one
81
+ // actually namespaces.
82
+ parsed.push({ id: handle, name, enabled: readsAsEnabled(state ?? "") });
83
+ }
84
+ }
85
+ return parsed.length > 0 ? parsed : null;
86
+ }
29
87
  /**
30
88
  * Every place the portal could reasonably have put its list, tried in order. An MCP tool result is
31
89
  * either structured or text, and a server that answers with `{ servers: [...] }` is as likely as
32
90
  * one that answers with a bare array.
91
+ *
92
+ * ⚠️ The prose reading comes last and is ADDITIONAL, never replacing. The portal is somebody else's
93
+ * product: it answers in prose today and may answer structured tomorrow, and a structured answer
94
+ * must keep winning on the day it arrives beside a human sentence.
33
95
  */
34
96
  function rows(answer) {
35
97
  const candidates = [answer.structuredContent];
@@ -42,8 +104,9 @@ function rows(answer) {
42
104
  candidates.push(JSON.parse(text));
43
105
  }
44
106
  catch {
45
- // A text block that is not JSON is prose, not a directory. Skip it rather than fail: the
46
- // portal may well add a human sentence beside the payload.
107
+ // A text block that is not JSON may still be the directory see `proseRows` above. It is
108
+ // not fed to the JSON readings here, and it does not make them fail either: the portal may
109
+ // well add a human sentence beside the payload.
47
110
  }
48
111
  }
49
112
  }
@@ -55,7 +118,7 @@ function rows(answer) {
55
118
  if (wrapped.success)
56
119
  return wrapped.data.servers;
57
120
  }
58
- return null;
121
+ return proseRows(answer);
59
122
  }
60
123
  /**
61
124
  * ⚠️ The rule itself — longest match against declared handles, never a cut at an underscore — is
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@anchrd/intel-api",
3
- "version": "0.10.0",
3
+ "version": "0.12.0",
4
4
  "type": "module",
5
5
  "license": "UNLICENSED",
6
6
  "repository": {
@@ -43,7 +43,7 @@
43
43
  },
44
44
  "dependencies": {
45
45
  "@anchrd/gate-sdk": "^0.7.0",
46
- "@anchrd/intel-contract": "^0.7.0",
46
+ "@anchrd/intel-contract": "^0.9.0",
47
47
  "@cfworker/json-schema": "^4.1.1",
48
48
  "@modelcontextprotocol/sdk": "^1.30.0",
49
49
  "fflate": "^0.8.3",