Build a tool approval hook
A tool approval hook gates dangerous tools behind a check you control. Novo POSTs to your URL right before the tool fires; you return allow, deny, or pause.
1. Set up the endpoint
// app/api/novo/approvals/route.ts
import { verifyNovoSignature } from 'novoagents';
export async function POST(req: Request) {
const raw = await req.text();
const result = verifyNovoSignature({
rawBody: raw,
signatureHeader: req.headers.get('Novo-Signature')!,
secret: process.env.NOVO_APPROVAL_SECRET!,
});
if (!result.valid) return new Response('bad signature', { status: 401 });
const body = JSON.parse(raw) as {
toolName: string;
input: { command?: string } & Record<string, unknown>;
};
// toolName is Novo's canonical public name, not a provider-native name.
// Returning `allow` auto-accepts the call: it fires with no pause.
if (body.toolName === 'read_file') {
return Response.json({ decision: 'allow' });
}
// Block known-dangerous shapes. The reason is shown to the model verbatim.
if (body.toolName === 'bash' && /(rm -rf|sudo|curl.*\|.*sh)/.test(body.input.command ?? '')) {
return Response.json({ decision: 'deny', reason: 'destructive command' });
}
// Everything else: pause and route to a human.
return Response.json({ decision: 'pause' });
}
For tools that are always safe to run, you don't even need a hook branch: list their canonical names in approvals.autoAllow on the agent and Novo skips the hook for them entirely:
await novo.agents.update(agent.id, {
approvals: {
toolExecution: { hookId: hook.id },
autoAllow: ['read_file', 'list_directory'],
},
});
2. Register the hook
const hook = await novo.toolApprovalHooks.register({
name: 'shell-gate',
url: 'https://api.acme.dev/novo/approvals',
secret: process.env.NOVO_APPROVAL_SECRET!,
onHookError: 'pause',
});
3. Attach to an agent
await novo.agents.update(agent.id, {
approvals: { toolExecution: { hookId: hook.id } },
});
Every tool call on the agent now goes through your hook. The hook's per-call response decides whether each tool fires, gets denied, or pauses for human resolution.
4. Handle the pause
When your hook returns pause, the run pauses with status: 'waiting_for_approval' and a data-novo-input-requested chunk fires on the stream with request.kind: 'approval_request'. Resolve from your code by selecting the approve or deny option:
for await (const event of stream) {
if (event.type === 'data-novo-input-requested' && event.data.request.kind === 'approval_request') {
const { id, toolName, request } = event.data;
const ok = await yourTeam.review({ toolName, input: request.input, preview: request.preview });
await novo.interactions.resolve(id, {
inputResponses: [{ requestId: id, optionId: ok ? 'approve' : 'deny' }],
reason: ok ? 'approved by on-call' : 'blocked',
});
}
}
A denial returns a tool-output-denied chunk for the call. The reason you pass rides along verbatim; Novo does not tag it with who denied (human vs. hook).
You can also resolve from a different process: Slack interaction, web dashboard, email link. The run waits.
5. Test before going live
const { decision, reason, latencyMs } = await novo.toolApprovalHooks.test(hook.id);
Sends a synthetic approval request to your URL. Confirm decision + signature verification + latency before flipping the agent on.
onHookError policy
Decides what happens when your hook URL itself fails (timeout, 5xx, unreachable host, or a 200 whose body is not a valid decision):
| onHookError | Behavior |
|---|---|
| deny | Block the tool call without pausing. The model sees tool-output-denied and decides what to do next. This is the default when you omit onHookError. |
| pause | Park the run at waiting_for_approval so a human can resolve it. Recommended when you have a human escalation path. |
| fail-run | Terminate the run with status: 'failed'. Use when a hook failure means you cannot safely continue. |
Failure and retry semantics
Novo delivers each review request exactly once. There is no automatic retry or redelivery; on any failure, onHookError is applied immediately:
- One delivery, bounded by
requestTimeoutMs. The POST is givenrequestTimeoutMs(default and hard ceiling 790 seconds). A timeout, a non-2xx status, an unreachable host, a refused redirect, or a200with an unparseable/invalid body each resolve straight to youronHookErrorpolicy. Novo does not re-POST after a transient5xx. pauseparks; it does not redeliver. Whetherpausecomes from your hook (returning{ "decision": "pause" }) or fromonHookError: 'pause', the run parks atwaiting_for_approvaland waits for an external decision viaPOST /v1/interactions/{id}/resolve(theresolveUrlis included in every request). The hook is never called again for that tool call.- Need to stall a call? Return
{ "decision": "pause" }with a200; that is the supported way to defer a decision you cannot make synchronously. Do not rely on returning a5xxto "buy time"; it will not be retried, and under the default policy it denies.
Idempotency: if the same review is presented again (durable replay), Novo returns the already-recorded decision instead of calling your hook a second time.
Worker (subagent) tool calls
When the tool call comes from a worker (a delegated subagent), the request and the resulting interaction carry the worker's lineage so you can map the call back to its root run without tracking it yourself:
runIdis the worker's own run id (the run that issued the tool call).rootRunIdis the tree root, always present; for a root-run call it equalsrunId.workerRefis the worker's address (wkr_…), present only for worker calls.
const body = JSON.parse(raw) as {
runId: string;
rootRunId: string;
workerRef?: string;
toolName: string;
// …
};
// Address the worker's sub-stream directly:
if (body.workerRef) {
const workerStream = await novo.runs.workers.stream(body.rootRunId, body.workerRef);
}
The same rootRunId/workerRef appear on the data-novo-input-requested chunk and on the Interaction object from interactions.get. For Novo-hosted MCP / HTTP-connection tool calls, the equivalent values arrive as the Novo-Root-Run-Id and Novo-Worker-Ref request headers (alongside Novo-Run-Id).
What to log
For every hook POST: timestamp, run ID, tool name, args, decision, your reason. This is the audit trail you'll wish you had the first time something goes wrong.
See also
- Tool approval hooks API: registration and lifecycle.
- Interactions API: resolving pending approvals and questions.
- Human in the loop: concept overview.