Tool approval hooks
A tool approval hook is a URL Novo calls before a tool fires. The hook responds with allow, deny, or pause. This is how you gate dangerous tools (shell, file edits, external API calls) behind a human check.
See Human in the loop for the resume flow.
Approval hooks can be registered from the console adapters screen for the common
name, url, and signing-secret path. Use the SDK/API for advanced fields such
as enabled, onHookError, requestTimeoutMs, and metadata.
Methods
| Method | Route |
|---|---|
| novo.toolApprovalHooks.register({...}) | POST /v1/tool-approval-hooks |
| novo.toolApprovalHooks.list() | GET /v1/tool-approval-hooks |
| novo.toolApprovalHooks.get(id) | GET /v1/tool-approval-hooks/:id |
| novo.toolApprovalHooks.update(id, {...}) | PATCH /v1/tool-approval-hooks/:id |
| novo.toolApprovalHooks.delete(id) | DELETE /v1/tool-approval-hooks/:id |
| novo.toolApprovalHooks.rotateSecret(id, { secret }) | POST /v1/tool-approval-hooks/:id/rotate-secret |
| novo.toolApprovalHooks.test(id) | POST /v1/tool-approval-hooks/:id/test |
Type signature
type ToolApprovalHooksNamespace = {
register(input: {
name: string;
url: string;
secret: string;
enabled?: boolean;
onHookError?: 'deny' | 'pause' | 'fail-run';
requestTimeoutMs?: number | null;
metadata?: Metadata;
}): Promise<ToolApprovalHook>;
list(): Promise<Page<ToolApprovalHook>>;
get(hookId: string): Promise<ToolApprovalHook>;
update(hookId: string, input: {
name?: string;
url?: string;
enabled?: boolean;
onHookError?: 'deny' | 'pause' | 'fail-run';
requestTimeoutMs?: number | null;
metadata?: MetadataPatch;
}): Promise<ToolApprovalHook>;
delete(hookId: string): Promise<{ id: string; object: 'tool_approval_hook'; deleted: true }>;
rotateSecret(hookId: string, input: { secret: string }): Promise<ToolApprovalHook>;
test(hookId: string): Promise<{
delivered: true;
latencyMs: number;
decision: 'allow' | 'deny' | 'pause';
reason?: string;
}>;
};
Register
const hook = await novo.toolApprovalHooks.register({
name: 'shell-gate',
url: 'https://api.acme.dev/novo/approvals',
secret: 'tah_secret_…',
onHookError: 'pause',
});
Attach to an agent:
await novo.agents.update(agent.id, {
approvals: { toolExecution: { hookId: hook.id } },
});
Once attached, every tool call on the agent goes through the hook. The hook decides per call whether to allow, deny, or pause.
The hook protocol
Every gated tool call POSTs to your URL:
POST {url} HTTP/1.1
Content-Type: application/json
Novo-Signature: t=…,v1=…
{
"type": "tool_execution_review",
"id": "tar_…",
"runId": "run_…",
"rootRunId": "run_…",
"workerRef": "wkr_…",
"threadId": "thread_…",
"agentId": "agent_…",
"stepNumber": 0,
"toolName": "bash",
"toolCallId": "tc_…",
"input": { "command": "rm -rf /" },
"preview": { "summary": "Will delete the workspace." },
"resolveUrl": "https://api.novoagents.ai/v1/interactions/tar_…/resolve"
}
The tool's args arrive as input. toolName is Novo's canonical provider-neutral tool name, so build approval policy on that value rather than provider-specific identifiers. preview is an optional rendered summary the engine prepared for humans (when available). resolveUrl is the stable URL to POST the resolution to if you want to skip the SDK.
runId is the run that issued the tool call; for a worker (subagent) call this is the worker's own run id. rootRunId is always present and is the tree root (it equals runId for a root-run call); workerRef (wkr_…) is present only for worker calls. Together they let you map a worker tool call back to its root run, and address the worker's sub-stream with runs.workers.stream(rootRunId, workerRef), without keeping a side table. For Novo-hosted MCP / HTTP-connection tool calls, the same values arrive as the Novo-Root-Run-Id and Novo-Worker-Ref request headers next to Novo-Run-Id.
Respond with:
{ "decision": "allow" }
or
{ "decision": "deny", "reason": "destructive command" }
or
{ "decision": "pause", "reason": "needs human approval" }
allow auto-accepts the call: the tool fires immediately with no pause. deny blocks the call and the model sees a tool-output-denied chunk; the reason you pass is surfaced verbatim, with no attribution framing. pause parks the run at waiting_for_approval, fires a data-novo-input-requested chunk (request.kind: 'approval_request'), and waits for novo.interactions.resolve(...) from your code. Tool approval hooks return only a decision and optional reason. Use the ask_question capability when you need custom questions or structured user actions.
Always-allow tools
To skip the hook entirely for known-safe tools, list their Novo canonical names in approvals.autoAllow on the agent. Listed tools never require approval and never POST to your hook:
await novo.agents.update(agent.id, {
approvals: {
toolExecution: { hookId: hook.id },
autoAllow: ['read_file', 'list_directory'],
},
});
This is the declarative analog of a hook that returns allow for those tools; use it for read-only tools so the hook only sees calls that warrant review.
On-hook-error policy
onHookError decides what happens when the hook URL itself fails (timeout, non-2xx, unreachable, or an invalid response body):
deny: block the tool call without pausing the run. Default whenonHookErroris omitted.pause: park the run atwaiting_for_approvalso a human can intervene.fail-run: terminate the run with afailedstatus.
Delivery is single: the request is sent once, bounded by requestTimeoutMs (default and ceiling 790 seconds), and there is no automatic retry or redelivery on a transient failure. To defer a decision you cannot make synchronously, return { "decision": "pause" } and resolve later via resolveUrl; do not depend on a 5xx being retried. See Build a tool approval hook for the full failure/retry semantics.
Test
const { decision, reason, latencyMs } = await novo.toolApprovalHooks.test(hook.id);
Sends a synthetic approval request to the URL and surfaces what the hook returned. Useful for verifying signature verification + decision logic before going live.
Errors
404 tool_approval_hook_not_found: bad ID.400 invalid_request_error: bad URL, invalidonHookError.