Compose
Each service is a building block. These are the canonical compositions used in production by OpenClaw agents on Helsinki.
Safe research workflow
The canonical pattern for any agent that accepts user-provided URLs or queries. Screen untrusted input → risk-check the URL → scrape → clean for LLM → validate output.
PromptGuard ($0.002) → LinkRisk ($0.005) → ScrapePay ($0.010) → MarkdownOpt ($0.005) → SchemaGate ($0.001)
Total cost per run: $0.023 USDC
import { McpClient } from "@modelcontextprotocol/sdk/client";
async function safeResearch(userQuery: string, targetUrl: string, schema: object) {
// 1. Screen the user input for prompt injection
const guard = await mcp.callTool("promptguard", {
prompt: userQuery,
sensitivity: "medium",
});
if (!guard.safe) throw new Error(`Input rejected: ${guard.risk}`);
// 2. Risk-check the URL before visiting
const risk = await mcp.callTool("linkrisk", { url: targetUrl });
if (risk.risk_level === "high") throw new Error(`URL blocked: ${risk.flags.join(", ")}`);
// 3. Scrape the page
const page = await mcp.callTool("scrapepay", {
url: targetUrl,
format: "html",
});
// 4. Clean the HTML to markdown for the LLM
const clean = await mcp.callTool("markdownopt", { html: page.content });
// 5. Call your LLM with the clean content
const llmOutput = await callLLM(userQuery, clean.markdown);
// 6. Validate the LLM output matches your expected schema
const valid = await mcp.callTool("schemagate", {
response: JSON.stringify(llmOutput),
schema,
});
if (!valid.valid) throw new Error(`LLM output invalid: ${valid.hint}`);
return llmOutput;
} Why this specific composition?
PromptGuard prevents jailbreak-style inputs from reaching your scraper or LLM. LinkRisk is cheap ($0.005) and catches obviously dangerous URLs before spending $0.01 on ScrapePay. MarkdownOpt's ~70% token reduction pays for itself on the first LLM call. SchemaGate catches hallucinated fields before they propagate downstream.
Notification pipeline
Monitor a page for changes, extract structured data, and alert when conditions are met. Works with any Telegram chat or public webhook endpoint.
ScrapePay ($0.010) → StructExtract ($0.002) → [condition check] → NotifyRelay /notify ($0.002)
Total cost per triggered alert: $0.014 USDC
async function monitorAndNotify(
pageUrl: string,
chatId: string,
previousCount: number
) {
// 1. Scrape the page as HTML for structured extraction
const page = await mcp.callTool("scrapepay", {
url: pageUrl,
format: "html",
});
// 2. Extract structured tables from the HTML
const data = await mcp.callTool("structextract", {
html: page.content,
extract: ["tables", "links"],
});
const currentCount = data.tables[0]?.rows.length ?? 0;
// 3. Only notify if something changed
if (currentCount > previousCount) {
await mcp.callTool("notifyrelay_telegram", {
chat_id: chatId,
message: [
`🆕 *${currentCount} items* found (was ${previousCount})`,
`Page: ${pageUrl}`,
].join("\n"),
});
return currentCount;
}
return previousCount;
} Why StructExtract before LLM?
Raw HTML from ScrapePay includes nav, ads, and boilerplate. Passing it to an LLM to count table rows costs 10–20× more in tokens than using StructExtract first. StructExtract costs $0.002 and returns clean, typed JSON. The LLM only sees what matters.
Document generation workflow
Fetch a web page, clean it to markdown, generate a PDF, and deliver it by email. Useful for report generation, document archival, and content delivery agents.
ScrapePay ($0.010) → MarkdownOpt ($0.005) → DocConvert-PDF ($0.005) → NotifyRelay /email ($0.005)
Total cost per document delivered: $0.025 USDC
async function generateAndDeliverReport(
reportUrl: string,
recipientEmail: string,
subject: string
) {
// 1. Fetch the live page
const page = await mcp.callTool("scrapepay", {
url: reportUrl,
format: "html",
});
// 2. Clean to markdown (removes nav, ads, etc.)
const clean = await mcp.callTool("markdownopt", { html: page.content });
// 3. Convert markdown to PDF
const pdf = await mcp.callTool("docconvert_pdf", {
from: "md",
to: "pdf",
content: clean.markdown,
});
// 4. Email the PDF
await mcp.callTool("notifyrelay_email", {
to: recipientEmail,
subject,
body: `Report generated from ${reportUrl}\n\n[PDF attached — base64 below]\n${pdf.content}`,
});
return {
pages: "(see PDF)",
token_reduction: clean.reduction_pct,
size_bytes: pdf.size_bytes,
};
} URL verification pipeline
Fast and authoritative URL safety checking for agents that handle user-supplied links. Start cheap, escalate to full sandbox only when needed.
LinkRisk ($0.005) → [if medium+] → LinkSafe ($0.010)
Cost: $0.005 for clean URLs, $0.015 for suspicious ones
async function verifyUrl(url: string) {
// Fast heuristic check first
const risk = await mcp.callTool("linkrisk", { url });
if (risk.risk_level === "high") {
return { safe: false, verdict: "blocked by heuristic", flags: risk.flags };
}
if (risk.risk_level === "medium") {
// Escalate to full Playwright sandbox + VirusTotal
const safe = await mcp.callTool("linksafe", { url });
return {
safe: safe.safe,
verdict: safe.safe ? "clean" : "threat detected",
threats: safe.threats,
virustotal_positives: safe.virustotal_positives,
};
}
return { safe: true, verdict: "clean (heuristic)", flags: risk.flags };
} Cache and convert workflow
For multi-agent pipelines where several agents may need the same page. Cache at the session level, extract and convert once.
CacheServe ($0.001) → StructExtract ($0.002) → DocConvert-Text ($0.001)
Total cost: $0.004 per session (even if 10 agents need the same page)
async function cachedFetchAndConvert(url: string) {
// Fetch with 1-hour cache — subsequent calls return the cached version
const cached = await mcp.callTool("cacheserve", {
url,
ttl_seconds: 3600,
});
if (cached.cached) {
console.log(`Cache hit from ${cached.cached_at}`);
}
// Extract tables and emails from the HTML
const structured = await mcp.callTool("structextract", {
html: cached.content,
extract: ["tables", "emails", "links"],
});
// Convert the tables to CSV for a spreadsheet agent
if (structured.tables.length > 0) {
const csv = await mcp.callTool("docconvert_text", {
from: "json",
to: "csv",
content: JSON.stringify(structured.tables[0]),
});
return csv.content;
}
return structured;
}