# Authoring a tool — Python (FastAPI)
The simplest tool is a FastAPI server that:
1. Verifies the inbound webhook's HMAC signature.
2. Parses the event.
3. Runs detection.
4. Returns a verdict.
The runtime's localhost proxy handles transport — your tool just needs
to verify the signature and respond within the deadline.
## Scaffold
```bash
openlatch-provider new tool --template python --out my-tool
cd my-tool
uv sync
```
The template includes a `main.py` with the verification + verdict
shape pre-wired.
## Minimum viable tool
```python
import hmac
import hashlib
import time
from fastapi import FastAPI, Request, HTTPException
WHSEC = bytes.fromhex(open(".secret").read().strip()) # whsec_live_… decoded
app = FastAPI()
@app.post("/event")
async def on_event(request: Request):
headers = request.headers
body = await request.body()
webhook_id = headers["webhook-id"]
webhook_ts = int(headers["webhook-timestamp"])
signature = headers["webhook-signature"] # "v1,<b64>"
if abs(time.time() - webhook_ts) > 300:
raise HTTPException(401, "timestamp skew")
expected = hmac.new(
WHSEC,
f"{webhook_id}.{webhook_ts}.".encode() + body,
hashlib.sha256,
).digest()
presented = signature.split(",", 1)[1]
import base64
presented_bytes = base64.b64decode(presented)
if not hmac.compare_digest(expected, presented_bytes):
raise HTTPException(401, "signature mismatch")
event = await request.json()
# ---- detection ----
return {
"riskScore": 5,
"verdictHint": "allow",
"ruleId": None,
"rationaleSummary": None,
"latencyMs": 1,
}
```
## Verdict shape
Per `schemas/platform/provider-call.schema.json`:
| `riskScore` | int 0-100 (nullable) | |
| `severityHint` | `"low"` / `"medium"` / `"high"` / `"critical"` | |
| `verdictHint` | `"allow"` / `"approve"` / `"deny"` | NOT `"block"` / `"flag"` |
| `ruleId` | string ≤120 | |
| `rationaleSummary` | string ≤500 | |
| `userFacing` | object | headline ≤120, body ≤2000, evidence ≤16 |
| `enrichment` | object | Arbitrary JSON. |
| `latencyMs` | int ≥0 | Tool-side measured time. |
Don't echo back `bindingId` / `eventId` / `category` — the platform
infers those from the request.
### Per-action scoring + prior config state (v2)
Optional. When the platform extracts discrete agent actions it expects a
parallel `actions[]` array keyed by a deterministic `actionRef`
(`"{kind}:{index}"`, kind ∈ `domain|file|command`):
```python
from openlatch_tool_sdk import ActionScore, score_to_severity
risk = 87
return Verdict(
risk_score=risk,
severity_hint=score_to_severity(risk),
verdict_hint="deny",
actions=[
ActionScore(
action_ref="cmd:0",
risk_score=risk,
severity=score_to_severity(risk), # MUST use the helper
threat_category="shell_dangerous", # routing 12-category
axes={"destructive": 18, "exfil": 0, "secret": 0,
"privesc": 0, "reversibility": 20},
)
],
)
```
- `actions` is optional and capped at 256 items; omit it and per-action
risk is recorded null (gap-tolerant).
- **`severity` must come from `score_to_severity()`** — 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 receive prior artifact state
as `event.prior_config_state` (the CloudEvents `priorconfigstate`
extension) — but only when the capability declares
`needs_prior_config_state: true` in `openlatch.yaml`.
## Best practices
- **Verify before parsing.** A malformed signature must short-circuit
before `request.json()`.
- **Constant-time compare** via `hmac.compare_digest`, not `==`.
- **Cap response size** — body must be ≤ 250 KB. Truncate
`userFacing.evidence` first.
- **Honor the deadline** in the `X-OpenLatch-Deadline-Ms` header.
Bail with a 504 if you can't return in time; the runtime forwards a
`OL-4228` to the platform.
- **No PII in logs.** The runtime audit log already records
outcome metadata; your tool should match (latency, verdict_hint —
never the payload).
## SDK
The `@openlatch/tool-sdk` (TS) and `openlatch-tool-sdk` (PyPI) ship a
ready-made FastAPI dependency that handles verification + verdict
shaping. See `tool-authoring/node.md` for the TS counterpart.
## Test against the runtime locally
```bash
# Start your tool:
uv run uvicorn main:app --port 8000
# Start the runtime against your manifest (with local_endpoint
# pointing at http://localhost:8000/event):
openlatch-provider listen --port 8443
# Send a synthetic event:
openlatch-provider trigger --binding bnd_yourid
```
`trigger` builds a synthetic event, HMAC-signs it with the local
binding secret, and POSTs to your running runtime. Verdict round-trip
+ audit log line lands in `~/.openlatch/provider/logs/`.