# Authoring a tool — Node.js (Express / Hono)
Same shape as the Python guide (`python.md`); language-specific notes
below.
## Scaffold
```bash
openlatch-provider new tool --template node --out my-tool
cd my-tool
pnpm install
```
## Minimum viable tool
```ts
import express from "express";
import crypto from "node:crypto";
import fs from "node:fs";
const WHSEC = fs.readFileSync(".secret", "utf8").trim();
const SECRET_BYTES = Buffer.from(WHSEC, "base64"); // strip whsec_ prefix first
const app = express();
app.use(express.raw({ type: "application/json", limit: "1mb" }));
app.post("/event", (req, res) => {
const id = req.header("webhook-id")!;
const ts = parseInt(req.header("webhook-timestamp")!, 10);
const sig = req.header("webhook-signature")!; // "v1,<b64>"
if (Math.abs(Date.now() / 1000 - ts) > 300) {
return res.status(401).send("timestamp skew");
}
const expected = crypto
.createHmac("sha256", SECRET_BYTES)
.update(`${id}.${ts}.`)
.update(req.body as Buffer)
.digest();
const presented = Buffer.from(sig.split(",", 2)[1], "base64");
if (!crypto.timingSafeEqual(expected, presented)) {
return res.status(401).send("signature mismatch");
}
const event = JSON.parse((req.body as Buffer).toString());
// ---- detection ----
res.json({
riskScore: 5,
verdictHint: "allow",
ruleId: null,
rationaleSummary: null,
latencyMs: 1,
});
});
app.listen(8000);
```
## Why express.raw, not express.json
The HMAC covers the **raw body bytes**. If you parse with
`express.json` first, Node will re-serialize on access and the
signature will fail. Always work with `Buffer` until verification
succeeds.
## Hono variant
```ts
import { Hono } from "hono";
import { timingSafeEqual } from "node:crypto";
// ... same handler shape; Hono's c.req.raw lives on c.env.body.
```
## Per-action scoring + prior config state (v2)
Optional. Return a parallel `actions[]` array keyed by the deterministic
`actionRef` (`"{kind}:{index}"`, kind ∈ `domain|file|command`):
```ts
import { scoreToSeverity, type ActionScore } from '@openlatch/tool-sdk';
const risk = 87;
return {
riskScore: risk,
severityHint: scoreToSeverity(risk),
verdictHint: 'deny',
actions: [
{
actionRef: 'cmd:0',
riskScore: risk,
severity: scoreToSeverity(risk), // MUST use the helper
threatCategory: 'shell_dangerous', // routing 12-category
axes: { destructive: 18, exfil: 0, secret: 0, privesc: 0, reversibility: 20 },
},
] satisfies ActionScore[],
};
```
- `actions` is optional, ≤256 items; omit it and per-action risk is
recorded null (gap-tolerant).
- **`severity` must come from `scoreToSeverity()`** — the platform
persists it verbatim, so first-party tools keep the canonical
`<40 / 40-69 / 70-89 / 90+` buckets true by construction.
- Stateful config / tool-integrity detectors read prior artifact state
from `event.priorconfigstate` — populated only when the capability
declares `needs_prior_config_state: true` in `openlatch.yaml`.
## Best practices
- **`crypto.timingSafeEqual`** for the signature compare. Never `===`
or `Buffer.compare` (both leak timing).
- **Body size cap**: enforce `1mb` at the framework level so a
malicious body never grows past the runtime's own cap.
- **No PII in logs**. Your tool's logs flow into your own
observability stack — keep the payload out and stick to outcome
metadata.
## TypeScript SDK
`@openlatch/tool-sdk` (npm) wraps the verification + verdict shaping
in a typed handler:
```ts
import { handler } from "@openlatch/tool-sdk";
export const POST = handler({
whsec: process.env.OPENLATCH_WHSEC!,
detect(event) {
return { riskScore: 5, verdictHint: "allow" };
},
});
```
Returns a Web `Request -> Response` handler usable in Express, Hono,
Bun, and edge runtimes.